Merge pull request #5 from jem-computer/feat/resources-and-prompts
Feat/resources and prompts
This commit is contained in:
commit
94ba6ff372
16 changed files with 919 additions and 55 deletions
40
README.md
40
README.md
|
|
@ -25,6 +25,9 @@ yarn add use-mcp
|
|||
- 🔄 Automatic connection management with reconnection and retries
|
||||
- 🔐 OAuth authentication flow handling with popup and fallback support
|
||||
- 📦 Simple React hook interface for MCP integration
|
||||
- 🧰 Full support for MCP tools, resources, and prompts
|
||||
- 📄 Access server resources and read their contents
|
||||
- 💬 Use server-provided prompt templates
|
||||
- 🧰 TypeScript types for editor assistance and type checking
|
||||
- 📝 Comprehensive logging for debugging
|
||||
- 🌐 Works with both HTTP and SSE (Server-Sent Events) transports
|
||||
|
|
@ -38,8 +41,12 @@ function MyAIComponent() {
|
|||
const {
|
||||
state, // Connection state: 'discovering' | 'pending_auth' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed'
|
||||
tools, // Available tools from MCP server
|
||||
resources, // Available resources from MCP server
|
||||
prompts, // Available prompts from MCP server
|
||||
error, // Error message if connection failed
|
||||
callTool, // Function to call tools on the MCP server
|
||||
readResource, // Function to read resource contents
|
||||
getPrompt, // Function to get prompt messages
|
||||
retry, // Retry connection manually
|
||||
authenticate, // Manually trigger authentication
|
||||
clearStorage, // Clear stored tokens and credentials
|
||||
|
|
@ -83,6 +90,32 @@ function MyAIComponent() {
|
|||
))}
|
||||
</ul>
|
||||
<button onClick={handleSearch}>Search</button>
|
||||
|
||||
{/* Example: Display and read resources */}
|
||||
{resources.length > 0 && (
|
||||
<div>
|
||||
<h3>Resources: {resources.length}</h3>
|
||||
<button onClick={async () => {
|
||||
const content = await readResource(resources[0].uri)
|
||||
console.log('Resource content:', content)
|
||||
}}>
|
||||
Read First Resource
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example: Use prompts */}
|
||||
{prompts.length > 0 && (
|
||||
<div>
|
||||
<h3>Prompts: {prompts.length}</h3>
|
||||
<button onClick={async () => {
|
||||
const result = await getPrompt(prompts[0].name)
|
||||
console.log('Prompt messages:', result.messages)
|
||||
}}>
|
||||
Get First Prompt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -176,10 +209,17 @@ function useMcp(options: UseMcpOptions): UseMcpResult
|
|||
|----------|------|-------------|
|
||||
| `state` | `string` | Current connection state: 'discovering', 'pending_auth', 'authenticating', 'connecting', 'loading', 'ready', 'failed' |
|
||||
| `tools` | `Tool[]` | Available tools from the MCP server |
|
||||
| `resources` | `Resource[]` | Available resources from the MCP server |
|
||||
| `resourceTemplates` | `ResourceTemplate[]` | Available resource templates from the MCP server |
|
||||
| `prompts` | `Prompt[]` | Available prompts from the MCP server |
|
||||
| `error` | `string \| undefined` | Error message if connection failed |
|
||||
| `authUrl` | `string \| undefined` | Manual authentication URL if popup is blocked |
|
||||
| `log` | `{ level: 'debug' \| 'info' \| 'warn' \| 'error'; message: string; timestamp: number }[]` | Array of log messages |
|
||||
| `callTool` | `(name: string, args?: Record<string, unknown>) => Promise<any>` | Function to call a tool on the MCP server |
|
||||
| `listResources` | `() => Promise<void>` | Refresh the list of available resources |
|
||||
| `readResource` | `(uri: string) => Promise<{ contents: Array<...> }>` | Read the contents of a specific resource |
|
||||
| `listPrompts` | `() => Promise<void>` | Refresh the list of available prompts |
|
||||
| `getPrompt` | `(name: string, args?: Record<string, string>) => Promise<{ messages: Array<...> }>` | Get a specific prompt with optional arguments |
|
||||
| `retry` | `() => void` | Manually attempt to reconnect |
|
||||
| `disconnect` | `() => void` | Disconnect from the MCP server |
|
||||
| `authenticate` | `() => void` | Manually trigger authentication |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ A minimal demo showcasing the `use-mcp` React hook for connecting to Model Conte
|
|||
|
||||
- Connect to any MCP server via URL
|
||||
- View available tools and their schemas
|
||||
- Browse and read server resources
|
||||
- Interact with server-provided prompts
|
||||
- Real-time connection status monitoring
|
||||
- Debug logging for troubleshooting
|
||||
- Clean, minimal UI focused on MCP functionality
|
||||
|
|
@ -31,7 +33,7 @@ pnpm dev
|
|||
|
||||
3. Open your browser and navigate to the displayed local URL
|
||||
|
||||
4. Enter an MCP server URL to test the connection and explore available tools
|
||||
4. Enter an MCP server URL to test the connection and explore available tools, resources, and prompts
|
||||
|
||||
## What This Demonstrates
|
||||
|
||||
|
|
@ -46,7 +48,17 @@ const connection = useMcp({
|
|||
autoRetry: false
|
||||
})
|
||||
|
||||
// Access connection.state, connection.tools, connection.error, etc.
|
||||
// Access connection.state, connection.tools, connection.resources,
|
||||
// connection.prompts, connection.error, etc.
|
||||
```
|
||||
|
||||
The `McpServers` component wraps this hook to provide a complete UI for server management and tool inspection.
|
||||
The `McpServers` component wraps this hook to provide a complete UI for server management, tool inspection, resource browsing, and prompt interaction.
|
||||
|
||||
## Supported MCP Features
|
||||
|
||||
- **Tools**: Execute server-provided tools with custom arguments and view results
|
||||
- **Resources**: Browse available resources and read their contents (text or binary)
|
||||
- **Resource Templates**: View dynamic resource templates with URI patterns
|
||||
- **Prompts**: Interact with server prompts, provide arguments, and view generated messages
|
||||
|
||||
Note: Not all MCP servers implement all features. The inspector will gracefully handle servers that only support a subset of the MCP specification.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useMcp, type Tool } from 'use-mcp/react'
|
||||
import { Info, X, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
import { useMcp, type Tool, type Resource, type ResourceTemplate, type Prompt } from 'use-mcp/react'
|
||||
import { Info, X, ChevronRight, ChevronDown, FileText, MessageSquare } from 'lucide-react'
|
||||
|
||||
// MCP Connection wrapper that only renders when active
|
||||
function McpConnection({
|
||||
|
|
@ -24,7 +24,16 @@ function McpConnection({
|
|||
// Update parent component with connection data
|
||||
useEffect(() => {
|
||||
onConnectionUpdate(connection)
|
||||
}, [connection.state, connection.tools, connection.error, connection.log.length, connection.authUrl])
|
||||
}, [
|
||||
connection.state,
|
||||
connection.tools,
|
||||
connection.resources,
|
||||
connection.resourceTemplates,
|
||||
connection.prompts,
|
||||
connection.error,
|
||||
connection.log.length,
|
||||
connection.authUrl,
|
||||
])
|
||||
|
||||
// Return null as this is just a hook wrapper
|
||||
return null
|
||||
|
|
@ -42,6 +51,9 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[])
|
|||
const [connectionData, setConnectionData] = useState<any>({
|
||||
state: 'not-connected',
|
||||
tools: [],
|
||||
resources: [],
|
||||
resourceTemplates: [],
|
||||
prompts: [],
|
||||
error: undefined,
|
||||
log: [],
|
||||
authUrl: undefined,
|
||||
|
|
@ -49,16 +61,26 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[])
|
|||
disconnect: () => {},
|
||||
authenticate: () => Promise.resolve(undefined),
|
||||
callTool: (_name: string, _args?: Record<string, unknown>) => Promise.resolve(undefined),
|
||||
listResources: () => Promise.resolve(),
|
||||
readResource: (_uri: string) => Promise.resolve({ contents: [] }),
|
||||
listPrompts: () => Promise.resolve(),
|
||||
getPrompt: (_name: string, _args?: Record<string, string>) => Promise.resolve({ messages: [] }),
|
||||
clearStorage: () => {},
|
||||
})
|
||||
const [toolForms, setToolForms] = useState<Record<string, Record<string, any>>>({})
|
||||
const [toolExecutionLogs, setToolExecutionLogs] = useState<Record<string, string>>({})
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({})
|
||||
const [expandedResources, setExpandedResources] = useState<Record<string, boolean>>({})
|
||||
const [expandedPrompts, setExpandedPrompts] = useState<Record<string, boolean>>({})
|
||||
const [resourceContents, setResourceContents] = useState<Record<string, any>>({})
|
||||
const [promptResults, setPromptResults] = useState<Record<string, any>>({})
|
||||
const [promptArgs, setPromptArgs] = useState<Record<string, Record<string, string>>>({})
|
||||
const logRef = useRef<HTMLDivElement>(null)
|
||||
const executionLogRefs = useRef<Record<string, HTMLTextAreaElement | null>>({})
|
||||
|
||||
// Extract connection properties
|
||||
const { state, tools, log, authUrl, disconnect, authenticate } = connectionData
|
||||
const { state, tools, resources, resourceTemplates, prompts, log, authUrl, disconnect, authenticate, readResource, getPrompt } =
|
||||
connectionData
|
||||
|
||||
// Notify parent component when tools change
|
||||
useEffect(() => {
|
||||
|
|
@ -85,6 +107,9 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[])
|
|||
setConnectionData({
|
||||
state: 'not-connected',
|
||||
tools: [],
|
||||
resources: [],
|
||||
resourceTemplates: [],
|
||||
prompts: [],
|
||||
error: undefined,
|
||||
log: [],
|
||||
authUrl: undefined,
|
||||
|
|
@ -92,6 +117,10 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[])
|
|||
disconnect: () => {},
|
||||
authenticate: () => Promise.resolve(undefined),
|
||||
callTool: (_name: string, _args?: Record<string, unknown>) => Promise.resolve(undefined),
|
||||
listResources: () => Promise.resolve(),
|
||||
readResource: (_uri: string) => Promise.resolve({ contents: [] }),
|
||||
listPrompts: () => Promise.resolve(),
|
||||
getPrompt: (_name: string, _args?: Record<string, string>) => Promise.resolve({ messages: [] }),
|
||||
clearStorage: () => {},
|
||||
})
|
||||
}
|
||||
|
|
@ -460,6 +489,218 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[])
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Resources section */}
|
||||
<div>
|
||||
<h3 className="font-medium text-sm mb-3">Available Resources ({resources.length + resourceTemplates.length})</h3>
|
||||
|
||||
{resources.length === 0 && resourceTemplates.length === 0 ? (
|
||||
<div className="border border-gray-200 rounded p-4 bg-gray-50 text-center text-gray-500 text-sm">
|
||||
No resources available. Connect to an MCP server to see available resources.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded p-4 bg-gray-50 space-y-2">
|
||||
{/* Direct Resources */}
|
||||
{resources.map((resource: Resource, index: number) => {
|
||||
const isExpanded = expandedResources[resource.uri] || false
|
||||
const contents = resourceContents[resource.uri]
|
||||
|
||||
return (
|
||||
<div key={`resource-${index}`} className="bg-white rounded border border-gray-100 shadow-sm">
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
onClick={() => setExpandedResources((prev) => ({ ...prev, [resource.uri]: !isExpanded }))}
|
||||
className="text-gray-500 hover:text-gray-700 mt-0.5"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-gray-400" />
|
||||
<h4 className="font-medium text-sm">{resource.name}</h4>
|
||||
</div>
|
||||
{resource.description && <p className="text-xs text-gray-500 mt-1">{resource.description}</p>}
|
||||
<p className="text-xs text-gray-400 mt-1 font-mono">{resource.uri}</p>
|
||||
{resource.mimeType && <span className="text-xs text-gray-400">({resource.mimeType})</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await readResource(resource.uri)
|
||||
setResourceContents((prev) => ({ ...prev, [resource.uri]: result }))
|
||||
} catch (error: any) {
|
||||
setResourceContents((prev) => ({ ...prev, [resource.uri]: { error: error.message } }))
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-medium"
|
||||
>
|
||||
Read Resource
|
||||
</button>
|
||||
|
||||
{contents && (
|
||||
<div className="mt-2">
|
||||
{contents.error ? (
|
||||
<div className="text-xs text-red-600 p-2 border border-red-200 rounded bg-red-50">
|
||||
Error: {contents.error}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{contents.contents?.map((content: any, idx: number) => (
|
||||
<div key={idx} className="border border-gray-200 rounded p-2">
|
||||
<div className="text-xs text-gray-500 mb-1">
|
||||
{content.uri} {content.mimeType && `(${content.mimeType})`}
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-gray-50 p-2 rounded overflow-x-auto">
|
||||
{content.text || (content.blob && '[Binary content]') || '[No content]'}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Resource Templates */}
|
||||
{resourceTemplates.map((template: ResourceTemplate, index: number) => (
|
||||
<div key={`template-${index}`} className="bg-white rounded border border-gray-100 shadow-sm">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-blue-400" />
|
||||
<h4 className="font-medium text-sm">{template.name}</h4>
|
||||
<span className="text-xs text-blue-600 bg-blue-50 px-2 py-0.5 rounded">Template</span>
|
||||
</div>
|
||||
{template.description && <p className="text-xs text-gray-500 mt-1">{template.description}</p>}
|
||||
<p className="text-xs text-gray-400 mt-1 font-mono">{template.uriTemplate}</p>
|
||||
{template.mimeType && <span className="text-xs text-gray-400">({template.mimeType})</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Prompts section */}
|
||||
<div>
|
||||
<h3 className="font-medium text-sm mb-3">Available Prompts ({prompts.length})</h3>
|
||||
|
||||
{prompts.length === 0 ? (
|
||||
<div className="border border-gray-200 rounded p-4 bg-gray-50 text-center text-gray-500 text-sm">
|
||||
No prompts available. Connect to an MCP server to see available prompts.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded p-4 bg-gray-50 space-y-2">
|
||||
{prompts.map((prompt: Prompt, index: number) => {
|
||||
const isExpanded = expandedPrompts[prompt.name] || false
|
||||
const result = promptResults[prompt.name]
|
||||
const args = promptArgs[prompt.name] || {}
|
||||
|
||||
return (
|
||||
<div key={index} className="bg-white rounded border border-gray-100 shadow-sm">
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
onClick={() => setExpandedPrompts((prev) => ({ ...prev, [prompt.name]: !isExpanded }))}
|
||||
className="text-gray-500 hover:text-gray-700 mt-0.5"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="text-purple-400" />
|
||||
<h4 className="font-medium text-sm">{prompt.name}</h4>
|
||||
</div>
|
||||
{prompt.description && <p className="text-xs text-gray-500 mt-1">{prompt.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{/* Prompt Arguments */}
|
||||
{prompt.arguments && prompt.arguments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-xs font-medium text-gray-700">Arguments:</h5>
|
||||
{prompt.arguments.map((arg: any) => (
|
||||
<div key={arg.name} className="space-y-1">
|
||||
<label className="text-xs text-gray-600">
|
||||
{arg.name}
|
||||
{arg.required && <span className="text-red-500 ml-1">*</span>}
|
||||
{arg.description && <span className="text-gray-400 ml-1">({arg.description})</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border border-gray-200 rounded text-sm"
|
||||
value={args[arg.name] || ''}
|
||||
onChange={(e) =>
|
||||
setPromptArgs((prev) => ({
|
||||
...prev,
|
||||
[prompt.name]: {
|
||||
...prev[prompt.name],
|
||||
[arg.name]: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder={arg.description || `Enter ${arg.name}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await getPrompt(prompt.name, args)
|
||||
setPromptResults((prev) => ({ ...prev, [prompt.name]: result }))
|
||||
} catch (error: any) {
|
||||
setPromptResults((prev) => ({ ...prev, [prompt.name]: { error: error.message } }))
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded text-xs font-medium"
|
||||
>
|
||||
Get Prompt
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<div className="mt-2">
|
||||
{result.error ? (
|
||||
<div className="text-xs text-red-600 p-2 border border-red-200 rounded bg-red-50">
|
||||
Error: {result.error}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-xs font-medium text-gray-700">Messages:</h5>
|
||||
{result.messages?.map((message: any, idx: number) => (
|
||||
<div key={idx} className="border border-gray-200 rounded p-2">
|
||||
<div className="text-xs font-medium text-gray-700 mb-1">{message.role}:</div>
|
||||
<pre className="text-xs font-mono bg-gray-50 p-2 rounded overflow-x-auto">
|
||||
{message.content.text || JSON.stringify(message.content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debug Log */}
|
||||
<div>
|
||||
<label className="font-medium text-xs block mb-2">Debug Log</label>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.0",
|
||||
"@modelcontextprotocol/sdk": "^1.13.3",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
13
examples/servers/cf-agents/pnpm-lock.yaml
generated
13
examples/servers/cf-agents/pnpm-lock.yaml
generated
|
|
@ -9,8 +9,8 @@ importers:
|
|||
.:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.13.0
|
||||
version: 1.13.2
|
||||
specifier: ^1.13.3
|
||||
version: 1.13.3
|
||||
zod:
|
||||
specifier: ^3.25.67
|
||||
version: 3.25.67
|
||||
|
|
@ -443,8 +443,8 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.13.2':
|
||||
resolution: {integrity: sha512-Vx7qOcmoKkR3qhaQ9qf3GxiVKCEu+zfJddHv6x3dY/9P6+uIwJnmuAur5aB+4FDXf41rRrDnOEGkviX5oYZ67w==}
|
||||
'@modelcontextprotocol/sdk@1.13.3':
|
||||
resolution: {integrity: sha512-bGwA78F/U5G2jrnsdRkPY3IwIwZeWUEfb5o764b79lb0rJmMT76TLwKhdNZOWakOQtedYefwIR4emisEMvInKA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@opentelemetry/api@1.9.0':
|
||||
|
|
@ -1314,13 +1314,14 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.2
|
||||
|
||||
'@modelcontextprotocol/sdk@1.13.2':
|
||||
'@modelcontextprotocol/sdk@1.13.3':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.3
|
||||
express: 5.1.0
|
||||
express-rate-limit: 7.5.1(express@5.1.0)
|
||||
pkce-challenge: 5.0.0
|
||||
|
|
@ -1349,7 +1350,7 @@ snapshots:
|
|||
|
||||
agents@0.0.100(@cloudflare/workers-types@4.20250628.0)(react@19.1.0):
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk': 1.13.2
|
||||
'@modelcontextprotocol/sdk': 1.13.3
|
||||
ai: 4.3.16(react@19.1.0)(zod@3.25.67)
|
||||
cron-schedule: 5.0.4
|
||||
nanoid: 5.1.5
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { McpAgent } from 'agents/mcp'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import OAuthProvider, { OAuthHelpers } from '@cloudflare/workers-oauth-provider'
|
||||
import OAuthProvider, { type OAuthHelpers } from '@cloudflare/workers-oauth-provider'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
// Define our MCP agent with tools
|
||||
|
|
@ -53,6 +53,95 @@ export class MyMCP extends McpAgent {
|
|||
return { content: [{ type: 'text', text: String(result) }] }
|
||||
},
|
||||
)
|
||||
|
||||
// Register sample resources using the agents framework API
|
||||
this.server.resource('calc://history', 'Calculation History', async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'calc://history',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
calculations: [
|
||||
{ operation: 'add', a: 5, b: 3, result: 8, timestamp: '2024-01-01T10:00:00Z' },
|
||||
{ operation: 'multiply', a: 4, b: 7, result: 28, timestamp: '2024-01-01T10:01:00Z' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
this.server.resource('calc://settings', 'Calculator Settings', async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'calc://settings',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
precision: 2,
|
||||
allowNegative: true,
|
||||
maxValue: 1000000,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// Register a dynamic resource
|
||||
this.server.resource('calc://stats', 'Calculation Statistics', async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'calc://stats',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
totalCalculations: Math.floor(Math.random() * 100),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// Register sample prompts using the agents framework API
|
||||
this.server.prompt(
|
||||
'math_problem',
|
||||
'Generate a math problem',
|
||||
{
|
||||
difficulty: z.string(),
|
||||
topic: z.string().optional(),
|
||||
},
|
||||
async ({ difficulty, topic }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Generate a ${difficulty} math problem${topic ? ` about ${topic}` : ''}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
this.server.prompt('explain_calculation', 'Explain a calculation', { operation: z.string() }, async ({ operation }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Please explain how to perform ${operation} step by step with examples`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@hono/mcp": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||
"@modelcontextprotocol/sdk": "^1.13.3",
|
||||
"wrangler": "^4.21.0",
|
||||
"zod": "^3.25.67"
|
||||
}
|
||||
|
|
|
|||
17
examples/servers/hono-mcp/pnpm-lock.yaml
generated
17
examples/servers/hono-mcp/pnpm-lock.yaml
generated
|
|
@ -14,10 +14,10 @@ importers:
|
|||
devDependencies:
|
||||
'@hono/mcp':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0(@modelcontextprotocol/sdk@1.13.1)(hono@4.8.2)
|
||||
version: 0.1.0(@modelcontextprotocol/sdk@1.13.3)(hono@4.8.2)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.13.1
|
||||
version: 1.13.1
|
||||
specifier: ^1.13.3
|
||||
version: 1.13.3
|
||||
wrangler:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
|
@ -352,8 +352,8 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.13.1':
|
||||
resolution: {integrity: sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==}
|
||||
'@modelcontextprotocol/sdk@1.13.3':
|
||||
resolution: {integrity: sha512-bGwA78F/U5G2jrnsdRkPY3IwIwZeWUEfb5o764b79lb0rJmMT76TLwKhdNZOWakOQtedYefwIR4emisEMvInKA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
accepts@2.0.0:
|
||||
|
|
@ -972,9 +972,9 @@ snapshots:
|
|||
|
||||
'@fastify/busboy@2.1.1': {}
|
||||
|
||||
'@hono/mcp@0.1.0(@modelcontextprotocol/sdk@1.13.1)(hono@4.8.2)':
|
||||
'@hono/mcp@0.1.0(@modelcontextprotocol/sdk@1.13.3)(hono@4.8.2)':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk': 1.13.1
|
||||
'@modelcontextprotocol/sdk': 1.13.3
|
||||
hono: 4.8.2
|
||||
|
||||
'@img/sharp-darwin-arm64@0.33.5':
|
||||
|
|
@ -1061,13 +1061,14 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@modelcontextprotocol/sdk@1.13.1':
|
||||
'@modelcontextprotocol/sdk@1.13.3':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.2
|
||||
express: 5.1.0
|
||||
express-rate-limit: 7.5.1(express@5.1.0)
|
||||
pkce-challenge: 5.0.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StreamableHTTPTransport } from '@hono/mcp'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
|
|
@ -17,19 +17,27 @@ app.use(
|
|||
|
||||
// Your MCP server implementation
|
||||
const mcpServer = new McpServer({
|
||||
name: 'my-mcp-server',
|
||||
name: 'vengabus-mcp-server',
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
mcpServer.registerTool(
|
||||
'add',
|
||||
'calculate_party_capacity',
|
||||
{
|
||||
title: 'Addition Tool',
|
||||
description: 'Add two numbers',
|
||||
inputSchema: { a: z.number(), b: z.number() },
|
||||
title: 'Party Bus Capacity Calculator',
|
||||
description: 'Calculate how many people can fit on the Vengabus',
|
||||
inputSchema: {
|
||||
busCount: z.number().describe('Number of Vengabuses'),
|
||||
peoplePerBus: z.number().describe('People per bus'),
|
||||
},
|
||||
},
|
||||
async ({ a, b }) => ({
|
||||
content: [{ type: 'text', text: String(a + b) }],
|
||||
async ({ busCount, peoplePerBus }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `🚌 ${busCount} Vengabuses can transport ${busCount * peoplePerBus} party people! The wheels of steel are turning! 🎉`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -42,18 +50,184 @@ mcpServer.registerTool(
|
|||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
await scheduler.wait(100 + Math.random() * 100)
|
||||
// Simulate some async work
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 100))
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Next Vengabus: imminent. Current user's route: New York to San Francisco. Route name: "Intercity Disco".`,
|
||||
text: `🚌 BOOM BOOM BOOM BOOM! Next Vengabus: IMMINENT! 🎉
|
||||
|
||||
Current Route: "Intercity Disco Express"
|
||||
From: New York
|
||||
To: IBIZA - THE MAGIC ISLAND! ✨
|
||||
|
||||
Schedule:
|
||||
- Departure: When the beat drops
|
||||
- Via: San Francisco (quick party stop)
|
||||
- Arrival: When the sun comes up
|
||||
|
||||
Status: The party bus is jumping!
|
||||
Passengers: Maximum capacity!
|
||||
BPM: 142 and rising!
|
||||
|
||||
We're going to Ibiza! Back to the island! 🏝️`,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Register sample resources
|
||||
mcpServer.registerResource(
|
||||
'config',
|
||||
'config://vengabus',
|
||||
{
|
||||
title: 'Vengabus Fleet Configuration',
|
||||
description: 'Current Vengabus fleet settings and party parameters',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
async (uri: URL) => {
|
||||
const normalizedUri = uri.href.replace(/\/$/, '')
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: normalizedUri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
version: '1.0.0',
|
||||
fleet: 'intercity-disco',
|
||||
features: {
|
||||
bassBoost: true,
|
||||
strobeEnabled: true,
|
||||
maxBPM: 160,
|
||||
wheelsOfSteel: 'turning',
|
||||
},
|
||||
routes: ['New York to Ibiza', 'Back to the Magic Island', 'Like an Intercity Disco', 'The place to be'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mcpServer.registerResource(
|
||||
'readme',
|
||||
'docs://party-manual.md',
|
||||
{
|
||||
title: 'Vengabus Party Manual',
|
||||
description: 'Official guide to the Vengabus experience',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
() => ({
|
||||
|
||||
contents: [
|
||||
{
|
||||
uri: 'docs://party-manual.md',
|
||||
mimeType: 'text/markdown',
|
||||
text: "# 🚌 Vengabus MCP Server\n\nBOOM BOOM BOOM BOOM! Welcome aboard the Vengabus! We like to party!\n\n## Features\n- 🎉 Party capacity calculator - How many can we take to Ibiza?\n- 🕐 Vengabus schedule checker - Next stop: The Magic Island!\n- 🎵 Fleet configuration with maximum bass boost\n- 🌟 Party statistics tracker (BPM, passengers, energy levels)\n\n## Routes\n- New York to Ibiza (via San Francisco)\n- Back to the Magic Island\n- Like an Intercity Disco\n- The place to be\n\n## Current Status\nThe wheels of steel are turning, and traffic lights are burning!\nWe're going to Ibiza! Woah! We're going to Ibiza!\n\n*Up, up and away we go!*",
|
||||
}
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
// Register a resource template
|
||||
mcpServer.registerResource(
|
||||
'stats',
|
||||
new ResourceTemplate('party://stats/{metric}', { list: undefined }),
|
||||
{
|
||||
title: 'Vengabus Party Statistics',
|
||||
description: 'Get party metrics (bpm, passengers, energy)',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
async (uri: URL, { metric }) => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
metric: metric,
|
||||
value:
|
||||
metric === 'bpm'
|
||||
? 140 + Math.floor(Math.random() * 20)
|
||||
: metric === 'passengers'
|
||||
? Math.floor(Math.random() * 50) + 20
|
||||
: metric === 'energy'
|
||||
? Math.floor(Math.random() * 100)
|
||||
: 0,
|
||||
unit:
|
||||
metric === 'bpm'
|
||||
? 'beats per minute'
|
||||
: metric === 'passengers'
|
||||
? 'party people'
|
||||
: metric === 'energy'
|
||||
? 'party power %'
|
||||
: 'unknown',
|
||||
status: 'The party is jumping!',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Register sample prompts
|
||||
mcpServer.registerPrompt(
|
||||
'party_invitation',
|
||||
{
|
||||
title: 'Vengabus Party Invitation',
|
||||
description: 'Generate a party invitation for the Vengabus',
|
||||
argsSchema: {
|
||||
name: z.string().describe('Name of the party person'),
|
||||
destination: z.string().optional().describe('Where the Vengabus is heading'),
|
||||
},
|
||||
},
|
||||
({ name, destination }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Create a party invitation for ${name} to join the Vengabus${destination ? ` heading to ${destination}` : ''}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
mcpServer.registerPrompt(
|
||||
'party_announcement',
|
||||
{
|
||||
title: 'Party Bus Announcement',
|
||||
description: 'Generate party-themed announcements for various occasions',
|
||||
argsSchema: {
|
||||
event: z.string().describe('Type of event (deployment, release, milestone, etc.)'),
|
||||
details: z.string().optional().describe('Additional details about the event'),
|
||||
},
|
||||
},
|
||||
({ event, details }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Create a party bus announcement for a ${event}${details ? `: ${details}` : ''}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
app.post('/mcp', async (c) => {
|
||||
const transport = new StreamableHTTPTransport()
|
||||
await mcpServer.connect(transport)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ The useMcp hook manages the connection to an MCP server, handles authentication
|
|||
```tsx
|
||||
import { useMcp } from 'use-mcp'
|
||||
// Optional: Import types if needed
|
||||
import type { UseMcpOptions, UseMcpResult, Tool } from 'use-mcp'
|
||||
import type { UseMcpOptions, UseMcpResult, Tool, Resource, ResourceTemplate, Prompt } from 'use-mcp'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
|
@ -101,7 +101,12 @@ function MyChatComponent() {
|
|||
</div>
|
||||
)}
|
||||
{mcp.state === 'authenticating' && <p>Waiting for authentication...</p>}
|
||||
{mcp.state === 'ready' && <p>Connected! Tools available: {mcp.tools.length}</p>}
|
||||
{mcp.state === 'ready' && (
|
||||
<p>
|
||||
Connected! Tools: {mcp.tools.length}, Resources: {mcp.resources.length + mcp.resourceTemplates.length},
|
||||
Prompts: {mcp.prompts.length}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Your Chat UI */}
|
||||
<div>
|
||||
|
|
@ -140,6 +145,52 @@ function MyChatComponent() {
|
|||
<button onClick={mcp.clearStorage} style={{ marginTop: '10px' }}>
|
||||
Clear Stored Auth
|
||||
</button>
|
||||
|
||||
{/* Example: Working with Resources */}
|
||||
{mcp.state === 'ready' && mcp.resources.length > 0 && (
|
||||
<div>
|
||||
<h3>Available Resources:</h3>
|
||||
{mcp.resources.map((resource) => (
|
||||
<div key={resource.uri}>
|
||||
<span>{resource.name}</span>
|
||||
<button onClick={async () => {
|
||||
try {
|
||||
const content = await mcp.readResource(resource.uri)
|
||||
console.log('Resource content:', content)
|
||||
} catch (error) {
|
||||
console.error('Failed to read resource:', error)
|
||||
}
|
||||
}}>
|
||||
Read
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example: Working with Prompts */}
|
||||
{mcp.state === 'ready' && mcp.prompts.length > 0 && (
|
||||
<div>
|
||||
<h3>Available Prompts:</h3>
|
||||
{mcp.prompts.map((prompt) => (
|
||||
<div key={prompt.name}>
|
||||
<span>{prompt.name}</span>
|
||||
<button onClick={async () => {
|
||||
try {
|
||||
const result = await mcp.getPrompt(prompt.name, {
|
||||
// Add any required arguments here
|
||||
})
|
||||
console.log('Prompt result:', result)
|
||||
} catch (error) {
|
||||
console.error('Failed to get prompt:', error)
|
||||
}
|
||||
}}>
|
||||
Get Prompt
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -163,11 +214,18 @@ export default MyChatComponent
|
|||
### Hook Return Value (UseMcpResult)
|
||||
|
||||
- tools: An array of Tool objects provided by the server. Empty until state is 'ready'.
|
||||
- resources: An array of Resource objects representing data available from the server.
|
||||
- resourceTemplates: An array of ResourceTemplate objects representing dynamic resources available from the server.
|
||||
- prompts: An array of Prompt objects representing reusable prompt templates from the server.
|
||||
- state: The current connection state ('discovering', 'authenticating', 'connecting', 'loading', 'ready', 'failed'). Use this to conditionally render UI or enable/disable features.
|
||||
- error: An error message if state is 'failed'.
|
||||
- authUrl: If authentication is required and the popup was potentially blocked, this URL string can be used to let the user manually open the auth page (e.g., <a href={authUrl} target="_blank">...</a>).
|
||||
- log: An array of log messages { level, message, timestamp }. Useful for debugging when debug option is true.
|
||||
- callTool(name, args): An async function to execute a tool on the MCP server. Throws an error if the client isn't ready or the call fails.
|
||||
- listResources(): An async function to refresh the list of available resources. Returns void.
|
||||
- readResource(uri): An async function to read the contents of a specific resource. Returns an object with a 'contents' array containing the resource data.
|
||||
- listPrompts(): An async function to refresh the list of available prompts. Returns void.
|
||||
- getPrompt(name, args): An async function to get a specific prompt with optional arguments. Returns an object with a 'messages' array containing the prompt messages.
|
||||
- retry(): Manually triggers a reconnection attempt if the state is 'failed'.
|
||||
- disconnect(): Disconnects the client from the server.
|
||||
- authenticate(): Manually attempts to start the authentication flow. Useful for triggering the popup via a user click if it was initially blocked.
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@
|
|||
export { useMcp } from './useMcp.js'
|
||||
export type { UseMcpOptions, UseMcpResult } from './types.js'
|
||||
|
||||
// Re-export core Tool type for convenience when using hook result?
|
||||
export type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
// Re-export core types for convenience when using hook result
|
||||
export type { Tool, Resource, ResourceTemplate, Prompt } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { Tool, Resource, ResourceTemplate, Prompt } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
export type UseMcpOptions = {
|
||||
/** The /sse URL of your remote MCP server */
|
||||
|
|
@ -35,6 +35,12 @@ export type UseMcpOptions = {
|
|||
export type UseMcpResult = {
|
||||
/** List of tools available from the connected MCP server */
|
||||
tools: Tool[]
|
||||
/** List of resources available from the connected MCP server */
|
||||
resources: Resource[]
|
||||
/** List of resource templates available from the connected MCP server */
|
||||
resourceTemplates: ResourceTemplate[]
|
||||
/** List of prompts available from the connected MCP server */
|
||||
prompts: Prompt[]
|
||||
/**
|
||||
* The current state of the MCP connection:
|
||||
* - 'discovering': Checking server existence and capabilities (including auth requirements).
|
||||
|
|
@ -63,6 +69,36 @@ export type UseMcpResult = {
|
|||
* @throws If the client is not in the 'ready' state or the call fails.
|
||||
*/
|
||||
callTool: (name: string, args?: Record<string, unknown>) => Promise<any>
|
||||
/**
|
||||
* Function to list resources from the MCP server.
|
||||
* @returns A promise that resolves when resources are refreshed.
|
||||
* @throws If the client is not in the 'ready' state.
|
||||
*/
|
||||
listResources: () => Promise<void>
|
||||
/**
|
||||
* Function to read a resource from the MCP server.
|
||||
* @param uri The URI of the resource to read.
|
||||
* @returns A promise that resolves with the resource contents.
|
||||
* @throws If the client is not in the 'ready' state or the read fails.
|
||||
*/
|
||||
readResource: (uri: string) => Promise<{ contents: Array<{ uri: string; mimeType?: string; text?: string; blob?: string }> }>
|
||||
/**
|
||||
* Function to list prompts from the MCP server.
|
||||
* @returns A promise that resolves when prompts are refreshed.
|
||||
* @throws If the client is not in the 'ready' state.
|
||||
*/
|
||||
listPrompts: () => Promise<void>
|
||||
/**
|
||||
* Function to get a specific prompt from the MCP server.
|
||||
* @param name The name of the prompt to get.
|
||||
* @param args Optional arguments for the prompt.
|
||||
* @returns A promise that resolves with the prompt messages.
|
||||
* @throws If the client is not in the 'ready' state or the get fails.
|
||||
*/
|
||||
getPrompt: (
|
||||
name: string,
|
||||
args?: Record<string, string>,
|
||||
) => Promise<{ messages: Array<{ role: 'user' | 'assistant'; content: { type: string; text?: string; [key: string]: any } }> }>
|
||||
/** Manually attempts to reconnect if the state is 'failed'. */
|
||||
retry: () => void
|
||||
/** Disconnects the client from the MCP server. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
// useMcp.ts
|
||||
import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
JSONRPCMessage,
|
||||
ListToolsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
GetPromptResultSchema,
|
||||
Tool,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
Prompt,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
// Import both transport types
|
||||
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
|
|
@ -39,6 +51,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
|
|||
|
||||
const [state, setState] = useState<UseMcpResult['state']>('discovering')
|
||||
const [tools, setTools] = useState<Tool[]>([])
|
||||
const [resources, setResources] = useState<Resource[]>([])
|
||||
const [resourceTemplates, setResourceTemplates] = useState<ResourceTemplate[]>([])
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([])
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [log, setLog] = useState<UseMcpResult['log']>([])
|
||||
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined)
|
||||
|
|
@ -95,6 +110,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
|
|||
if (isMountedRef.current && !quiet) {
|
||||
setState('discovering')
|
||||
setTools([])
|
||||
setResources([])
|
||||
setResourceTemplates([])
|
||||
setPrompts([])
|
||||
setError(undefined)
|
||||
setAuthUrl(undefined)
|
||||
}
|
||||
|
|
@ -283,16 +301,49 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
|
|||
await clientRef.current!.connect(transportInstance)
|
||||
|
||||
// --- Success Path ---
|
||||
addLog('info', `Client connected via ${transportType.toUpperCase()}. Loading tools...`)
|
||||
addLog('info', `Client connected via ${transportType.toUpperCase()}. Loading tools, resources, and prompts...`)
|
||||
successfulTransportRef.current = transportType // Store successful type
|
||||
setState('loading')
|
||||
|
||||
const toolsResponse = await clientRef.current!.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||
|
||||
// Load resources after tools (optional - not all servers support resources)
|
||||
let resourcesResponse: { resources: Resource[]; resourceTemplates?: ResourceTemplate[] } = { resources: [], resourceTemplates: [] }
|
||||
try {
|
||||
resourcesResponse = await clientRef.current!.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||
} catch (err) {
|
||||
addLog('debug', 'Server does not support resources/list method', err)
|
||||
}
|
||||
|
||||
// Load prompts after resources (optional - not all servers support prompts)
|
||||
let promptsResponse: { prompts: Prompt[] } = { prompts: [] }
|
||||
try {
|
||||
promptsResponse = await clientRef.current!.request({ method: 'prompts/list' }, ListPromptsResultSchema)
|
||||
} catch (err) {
|
||||
addLog('debug', 'Server does not support prompts/list method', err)
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
// Check mount before final state updates
|
||||
setTools(toolsResponse.tools)
|
||||
addLog('info', `Loaded ${toolsResponse.tools.length} tools.`)
|
||||
setResources(resourcesResponse.resources)
|
||||
setResourceTemplates(Array.isArray(resourcesResponse.resourceTemplates) ? resourcesResponse.resourceTemplates : [])
|
||||
setPrompts(promptsResponse.prompts)
|
||||
const summary = [`Loaded ${toolsResponse.tools.length} tools`]
|
||||
if (
|
||||
resourcesResponse.resources.length > 0 ||
|
||||
(resourcesResponse.resourceTemplates && resourcesResponse.resourceTemplates.length > 0)
|
||||
) {
|
||||
summary.push(`${resourcesResponse.resources.length} resources`)
|
||||
if (Array.isArray(resourcesResponse.resourceTemplates) && resourcesResponse.resourceTemplates.length > 0) {
|
||||
summary.push(`${resourcesResponse.resourceTemplates.length} resource templates`)
|
||||
}
|
||||
}
|
||||
if (promptsResponse.prompts.length > 0) {
|
||||
summary.push(`${promptsResponse.prompts.length} prompts`)
|
||||
}
|
||||
|
||||
addLog('info', summary.join(', ') + '.')
|
||||
setState('ready') // Final success state
|
||||
// connectingRef will be set to false after orchestration logic
|
||||
connectAttemptRef.current = 0 // Reset on success
|
||||
|
|
@ -604,6 +655,88 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
|
|||
}
|
||||
}, [url, addLog, disconnect]) // Depends on url and stable callbacks
|
||||
|
||||
// listResources is stable (depends on stable addLog)
|
||||
const listResources = useCallback(async () => {
|
||||
// Use stateRef for check, state for throwing error message
|
||||
if (stateRef.current !== 'ready' || !clientRef.current) {
|
||||
throw new Error(`MCP client is not ready (current state: ${state}). Cannot list resources.`)
|
||||
}
|
||||
addLog('info', 'Listing resources...')
|
||||
try {
|
||||
const resourcesResponse = await clientRef.current.request({ method: 'resources/list' }, ListResourcesResultSchema)
|
||||
if (isMountedRef.current) {
|
||||
setResources(resourcesResponse.resources)
|
||||
setResourceTemplates(Array.isArray(resourcesResponse.resourceTemplates) ? resourcesResponse.resourceTemplates : [])
|
||||
addLog(
|
||||
'info',
|
||||
`Listed ${resourcesResponse.resources.length} resources, ${Array.isArray(resourcesResponse.resourceTemplates) ? resourcesResponse.resourceTemplates.length : 0} resource templates.`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
addLog('error', `Error listing resources: ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
throw err
|
||||
}
|
||||
}, [state, addLog]) // Depends on state for error message and stable addLog
|
||||
|
||||
// readResource is stable (depends on stable addLog)
|
||||
const readResource = useCallback(
|
||||
async (uri: string) => {
|
||||
// Use stateRef for check, state for throwing error message
|
||||
if (stateRef.current !== 'ready' || !clientRef.current) {
|
||||
throw new Error(`MCP client is not ready (current state: ${state}). Cannot read resource "${uri}".`)
|
||||
}
|
||||
addLog('info', `Reading resource: ${uri}`)
|
||||
try {
|
||||
const result = await clientRef.current.request({ method: 'resources/read', params: { uri } }, ReadResourceResultSchema)
|
||||
addLog('info', `Resource "${uri}" read successfully`)
|
||||
return result
|
||||
} catch (err) {
|
||||
addLog('error', `Error reading resource "${uri}": ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[state, addLog],
|
||||
) // Depends on state for error message and stable addLog
|
||||
|
||||
// listPrompts is stable (depends on stable addLog)
|
||||
const listPrompts = useCallback(async () => {
|
||||
// Use stateRef for check, state for throwing error message
|
||||
if (stateRef.current !== 'ready' || !clientRef.current) {
|
||||
throw new Error(`MCP client is not ready (current state: ${state}). Cannot list prompts.`)
|
||||
}
|
||||
addLog('info', 'Listing prompts...')
|
||||
try {
|
||||
const promptsResponse = await clientRef.current.request({ method: 'prompts/list' }, ListPromptsResultSchema)
|
||||
if (isMountedRef.current) {
|
||||
setPrompts(promptsResponse.prompts)
|
||||
addLog('info', `Listed ${promptsResponse.prompts.length} prompts.`)
|
||||
}
|
||||
} catch (err) {
|
||||
addLog('error', `Error listing prompts: ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
throw err
|
||||
}
|
||||
}, [state, addLog]) // Depends on state for error message and stable addLog
|
||||
|
||||
// getPrompt is stable (depends on stable addLog)
|
||||
const getPrompt = useCallback(
|
||||
async (name: string, args?: Record<string, string>) => {
|
||||
// Use stateRef for check, state for throwing error message
|
||||
if (stateRef.current !== 'ready' || !clientRef.current) {
|
||||
throw new Error(`MCP client is not ready (current state: ${state}). Cannot get prompt "${name}".`)
|
||||
}
|
||||
addLog('info', `Getting prompt: ${name}`, args)
|
||||
try {
|
||||
const result = await clientRef.current.request({ method: 'prompts/get', params: { name, arguments: args } }, GetPromptResultSchema)
|
||||
addLog('info', `Prompt "${name}" retrieved successfully`)
|
||||
return result
|
||||
} catch (err) {
|
||||
addLog('error', `Error getting prompt "${name}": ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[state, addLog],
|
||||
) // Depends on state for error message and stable addLog
|
||||
|
||||
// ===== Effects =====
|
||||
|
||||
// Effect for handling auth callback messages from popup (Stable dependencies)
|
||||
|
|
@ -691,10 +824,17 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
|
|||
return {
|
||||
state,
|
||||
tools,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
prompts,
|
||||
error,
|
||||
log,
|
||||
authUrl,
|
||||
callTool,
|
||||
listResources,
|
||||
readResource,
|
||||
listPrompts,
|
||||
getPrompt,
|
||||
retry,
|
||||
disconnect,
|
||||
authenticate,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
|
||||
import { chromium, Browser, Page } from 'playwright'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { SERVER_CONFIGS } from './server-configs.js'
|
||||
import { getTestState, connectToMCPServer, cleanupProcess } from './test-utils.js'
|
||||
|
||||
|
|
@ -82,9 +82,33 @@ describe('MCP Connection Integration Tests', () => {
|
|||
console.log(` ${index + 1}. ${tool}`)
|
||||
})
|
||||
|
||||
if (result.resources.length > 0) {
|
||||
console.log(`📂 Available resources (${result.resources.length}):`)
|
||||
result.resources.forEach((resource, index) => {
|
||||
console.log(` ${index + 1}. ${resource}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (result.prompts.length > 0) {
|
||||
console.log(`💬 Available prompts (${result.prompts.length}):`)
|
||||
result.prompts.forEach((prompt, index) => {
|
||||
console.log(` ${index + 1}. ${prompt}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Verify connection success
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.tools.length).toBeGreaterThanOrEqual(serverConfig.expectedTools)
|
||||
|
||||
// Verify resources if expected
|
||||
if (serverConfig.expectedResources !== undefined) {
|
||||
expect(result.resources.length).toBeGreaterThanOrEqual(serverConfig.expectedResources)
|
||||
}
|
||||
|
||||
// Verify prompts if expected
|
||||
if (serverConfig.expectedPrompts !== undefined) {
|
||||
expect(result.prompts.length).toBeGreaterThanOrEqual(serverConfig.expectedPrompts)
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ Failed to connect to ${serverConfig.name}`)
|
||||
if (result.debugLog) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { ServerConfig } from './test-utils.js'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { ServerConfig } from './test-utils.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = join(__dirname, '../..')
|
||||
|
|
@ -19,7 +19,9 @@ export const SERVER_CONFIGS: ServerConfig[] = [
|
|||
transportTypes: ['auto', 'http'],
|
||||
},
|
||||
],
|
||||
expectedTools: 1,
|
||||
expectedTools: 2,
|
||||
expectedResources: 2, // 2 direct resources (templates are counted separately)
|
||||
expectedPrompts: 2,
|
||||
},
|
||||
{
|
||||
name: 'cf-agents',
|
||||
|
|
@ -43,6 +45,8 @@ export const SERVER_CONFIGS: ServerConfig[] = [
|
|||
transportTypes: ['auto', 'sse'],
|
||||
},
|
||||
],
|
||||
expectedTools: 1,
|
||||
expectedTools: 2,
|
||||
expectedResources: 3, // 3 direct resources (no templates in cf-agents)
|
||||
expectedPrompts: 2,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { spawn, ChildProcess } from 'child_process'
|
||||
import { Page } from 'playwright'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import type { Page } from 'playwright'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const testDir = join(__dirname, '..')
|
||||
|
|
@ -25,6 +25,8 @@ export interface ServerConfig {
|
|||
portKey: keyof TestState
|
||||
endpoints: ServerEndpoint[]
|
||||
expectedTools: number
|
||||
expectedResources?: number
|
||||
expectedPrompts?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -132,7 +134,7 @@ export async function connectToMCPServer(
|
|||
page: Page,
|
||||
serverUrl: string,
|
||||
transportType: 'auto' | 'http' | 'sse' = 'auto',
|
||||
): Promise<{ success: boolean; tools: string[]; debugLog: string }> {
|
||||
): Promise<{ success: boolean; tools: string[]; resources: string[]; prompts: string[]; debugLog: string }> {
|
||||
const state = getTestState()
|
||||
|
||||
if (!state.staticPort) {
|
||||
|
|
@ -197,7 +199,9 @@ export async function connectToMCPServer(
|
|||
// Extract available tools
|
||||
const tools: string[] = []
|
||||
try {
|
||||
const toolCards = page.locator('.bg-white.rounded.border')
|
||||
// Look for the Available Tools section
|
||||
const toolsSection = page.locator('h3:has-text("Available Tools")').locator('..')
|
||||
const toolCards = toolsSection.locator('.bg-white.rounded.border')
|
||||
const toolCount = await toolCards.count()
|
||||
|
||||
for (let i = 0; i < toolCount; i++) {
|
||||
|
|
@ -211,6 +215,44 @@ export async function connectToMCPServer(
|
|||
console.warn('Could not extract tools list:', e)
|
||||
}
|
||||
|
||||
// Extract available resources
|
||||
const resources: string[] = []
|
||||
try {
|
||||
// Look for the Available Resources section
|
||||
const resourcesSection = page.locator('h3:has-text("Available Resources")').locator('..')
|
||||
const resourceCards = resourcesSection.locator('.bg-white.rounded.border')
|
||||
const resourceCount = await resourceCards.count()
|
||||
|
||||
for (let i = 0; i < resourceCount; i++) {
|
||||
const resourceNameElement = resourceCards.nth(i).locator('h4.font-medium.text-sm')
|
||||
const resourceName = await resourceNameElement.textContent()
|
||||
if (resourceName?.trim()) {
|
||||
resources.push(resourceName.trim())
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not extract resources list:', e)
|
||||
}
|
||||
|
||||
// Extract available prompts
|
||||
const prompts: string[] = []
|
||||
try {
|
||||
// Look for the Available Prompts section
|
||||
const promptsSection = page.locator('h3:has-text("Available Prompts")').locator('..')
|
||||
const promptCards = promptsSection.locator('.bg-white.rounded.border')
|
||||
const promptCount = await promptCards.count()
|
||||
|
||||
for (let i = 0; i < promptCount; i++) {
|
||||
const promptNameElement = promptCards.nth(i).locator('h4.font-medium.text-sm')
|
||||
const promptName = await promptNameElement.textContent()
|
||||
if (promptName?.trim()) {
|
||||
prompts.push(promptName.trim())
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not extract prompts list:', e)
|
||||
}
|
||||
|
||||
// Extract debug log
|
||||
let debugLog = ''
|
||||
try {
|
||||
|
|
@ -225,6 +267,8 @@ export async function connectToMCPServer(
|
|||
return {
|
||||
success: isConnected,
|
||||
tools,
|
||||
resources,
|
||||
prompts,
|
||||
debugLog,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue