diff --git a/examples/mcp-elicitation-demo/README.md b/examples/mcp-elicitation-demo/README.md deleted file mode 100644 index 77c5c98b..00000000 --- a/examples/mcp-elicitation-demo/README.md +++ /dev/null @@ -1,293 +0,0 @@ -# MCP Elicitation Demo - -This is a MCP client-server example that shows how to use elicitation support using the Agents SDK. - -- **Full MCP compliance** with https://modelcontextprotocol.io/specification/draft/client/elicitation - -### MCP Server (`McpServerAgent`) - -Here's the actual working code from our demo: - -```typescript -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpAgent, type ElicitResult } from "agents/mcp"; -import { - Agent, - type AgentNamespace, - routeAgentRequest, - callable, - type Connection, - type WSMessage -} from "agents"; -import { z } from "zod"; - -type Env = { - MyAgent: AgentNamespace; - McpServerAgent: DurableObjectNamespace; - HOST: string; -}; - -export class McpServerAgent extends McpAgent { - server = new McpServer({ - name: "Elicitation Demo Server", - version: "1.0.0" - }) - - initialState = { counter: 0 }; - - // Track active session for cross-agent elicitation - private activeSession: string | null = null; - - async elicitInput(params: { - message: string; - requestedSchema: { - type: string; - properties?: Record< - string, - { - type: string; - title?: string; - description?: string; - format?: string; - enum?: string[]; - enumNames?: string[]; - } - >; - required?: string[]; - }; - }): Promise { - if (!this.activeSession) { - throw new Error("No active client session found for elicitation"); - } - - // Get the MyAgent instance that handles browser communication - const myAgentId = this.env.MyAgent.idFromName(this.activeSession); - const myAgent = this.env.MyAgent.get(myAgentId); - - // Create MCP-compliant elicitation request - const requestId = `elicit_${Math.random().toString(36).substring(2, 11)}`; - const elicitRequest = { - jsonrpc: "2.0" as const, - id: requestId, - method: "elicitation/create", - params: { - message: params.message, - requestedSchema: params.requestedSchema - } - }; - - // Forward request to MyAgent which communicates with browser - const response = await myAgent.fetch( - new Request("https://internal/elicit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(elicitRequest) - }) - ); - - if (!response.ok) { - throw new Error("Failed to send elicitation request"); - } - - return (await response.json()) as ElicitResult; - } - - async init() { - // Counter tool with user confirmation via elicitation - this.server.tool( - "increment-counter", - "Increment the counter with user confirmation", - { - amount: z.number().describe("Amount to increment by").default(1), - __clientSession: z - .string() - .optional() - .describe("Internal client session ID") - }, - async ({ - amount, - __clientSession - }: { - amount: number; - __clientSession?: string; - }) => { - // Store session for cross-agent elicitation - if (__clientSession) { - this.activeSession = __clientSession; - } - - // Request user confirmation via elicitation - const confirmation = await this.elicitInput({ - message: `Are you sure you want to increment the counter by ${amount}?`, - requestedSchema: { - type: "object", - properties: { - confirmed: { - type: "boolean", - title: "Confirm increment", - description: "Check to confirm the increment" - } - }, - required: ["confirmed"] - } - }); - - if ( - confirmation.action === "accept" && - confirmation.content?.confirmed - ) { - this.setState({ - counter: this.state.counter + amount - }); - - return { - content: [ - { - type: "text", - text: `Counter incremented by ${amount}. New value: ${this.state.counter}` - } - ] - }; - } else { - return { - content: [ - { - type: "text", - text: "Counter increment cancelled." - } - ] - }; - } - } - ); - - // User creation tool with form-based elicitation - this.server.tool( - "create-user", - "Create a new user with form input", - { - username: z.string().describe("Username for the new user"), - __clientSession: z - .string() - .optional() - .describe("Internal client session ID") - }, - async ({ - username, - __clientSession - }: { - username: string; - __clientSession?: string; - }) => { - // Store session for cross-agent elicitation - if (__clientSession) { - this.activeSession = __clientSession; - } - - // Request user details via elicitation - const userInfo = await this.elicitInput({ - message: `Create user account for "${username}":`, - requestedSchema: { - type: "object", - properties: { - email: { - type: "string", - format: "email", - title: "Email Address", - description: "User's email address" - }, - role: { - type: "string", - title: "Role", - enum: ["viewer", "editor", "admin"], - enumNames: ["Viewer", "Editor", "Admin"] - }, - sendWelcome: { - type: "boolean", - title: "Send Welcome Email", - description: "Send welcome email to user" - } - }, - required: ["email", "role"] - } - }); - - if (userInfo.action === "accept" && userInfo.content) { - const details = userInfo.content; - return { - content: [ - { - type: "text", - text: `User created:\n• Username: ${username}\n• Email: ${details.email}\n• Role: ${details.role}\n• Welcome email: ${details.sendWelcome ? "Yes" : "No"}` - } - ] - }; - } else { - return { - content: [ - { - type: "text", - text: "User creation cancelled." - } - ] - }; - } - } - ); - } -``` - -## Getting Started - -1. Install dependencies: - - ```bash - npm install - ``` - -2. Start the development server: - - ```bash - npm start - ``` - -3. Open your browser (typically http://localhost:5173/) - -4. The demo auto-connects to the local MCP server - -5. Try the elicitation tools: - - **Increment Counter**: Click to see boolean confirmation elicitation - - **Create User**: Click to see complex form elicitation - -### In production, your MCP server typically connects directly to clients, making elicitation much simpler: - -```typescript -// Direct MCP connection -export class MyMcpServer extends McpAgent { - async init() { - this.server.tool( - "my-tool", - "My tool", - { input: z.string() }, - async ({ input }) => { - // elicitInput() works directly - const result = await this.elicitInput({ - message: "Confirm this action?", - requestedSchema: { - type: "object", - properties: { - confirmed: { type: "boolean", title: "Confirm" } - }, - required: ["confirmed"] - } - }); - - if (result.action === "accept" && result.content?.confirmed) { - return { content: [{ type: "text", text: "Action completed!" }] }; - } - return { content: [{ type: "text", text: "Action cancelled." }] }; - } - ); - } -} -``` diff --git a/examples/mcp-elicitation-demo/index.html b/examples/mcp-elicitation-demo/index.html deleted file mode 100644 index b4fd3e61..00000000 --- a/examples/mcp-elicitation-demo/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - MCP Elicitation Demo - - -
- - - diff --git a/examples/mcp-elicitation-demo/package.json b/examples/mcp-elicitation-demo/package.json deleted file mode 100644 index d6152bce..00000000 --- a/examples/mcp-elicitation-demo/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "author": "", - "dependencies": { - "nanoid": "^5.1.6", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "zod": "^3.25.76" - }, - "keywords": [], - "name": "@cloudflare/agents-mcp-elicitation-demo", - "private": true, - "scripts": { - "build": "vite build", - "deploy": "wrangler deploy", - "start": "vite dev" - }, - "type": "module" -} diff --git a/examples/mcp-elicitation-demo/src/client.tsx b/examples/mcp-elicitation-demo/src/client.tsx deleted file mode 100644 index 6aaf11a7..00000000 --- a/examples/mcp-elicitation-demo/src/client.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { useAgent } from "agents/react"; -import { useState } from "react"; -import { createRoot } from "react-dom/client"; -import type { MCPServersState } from "agents"; -import { agentFetch } from "agents/client"; -import { nanoid } from "nanoid"; -import "./styles.css"; - -// Force new session for this demo -const sessionId = `demo-${nanoid(8)}`; - -function App() { - const [isConnected, setIsConnected] = useState(false); - const [mcpState, setMcpState] = useState({ - prompts: [], - resources: [], - servers: {}, - tools: [] - }); - const [elicitationRequest, setElicitationRequest] = useState<{ - id: string; - message: string; - schema: { - type: string; - properties?: Record< - string, - { - type: string; - title?: string; - description?: string; - format?: string; - enum?: string[]; - enumNames?: string[]; - } - >; - required?: string[]; - }; - resolve: (formData: Record) => void; - reject: () => void; - cancel: () => void; - } | null>(null); - const [formData, setFormData] = useState>({}); - const [toolResults, setToolResults] = useState< - Array<{ tool: string; result: string; timestamp: number }> - >([]); - - const agent = useAgent({ - agent: "MyAgent", - name: sessionId, - onClose: () => setIsConnected(false), - onOpen: () => { - setIsConnected(true); - // Auto-connect local server on startup - setTimeout(() => { - addLocalServer(); - }, 1000); - }, - onMcpUpdate: (mcpServers: MCPServersState) => { - setMcpState(mcpServers); - }, - onMessage: (message: { data: string }) => { - // Handle elicitation requests from MCP server following MCP specification - try { - const parsed = JSON.parse(message.data); - if (parsed.method === "elicitation/create") { - setElicitationRequest({ - id: parsed.id, - message: parsed.params.message, - schema: parsed.params.requestedSchema, - resolve: (formData: Record) => { - // Send elicitation response back to server following MCP spec - const response = { - jsonrpc: "2.0", - id: parsed.id, - result: { - action: "accept", - content: formData - } - }; - console.log("Sending elicitation response:", response); - agent.send(JSON.stringify(response)); - setElicitationRequest(null); - setFormData({}); - }, - reject: () => { - // Send decline back to server following MCP spec - agent.send( - JSON.stringify({ - jsonrpc: "2.0", - id: parsed.id, - result: { - action: "decline" - } - }) - ); - setElicitationRequest(null); - setFormData({}); - }, - cancel: () => { - // Send cancel back to server following MCP spec - agent.send( - JSON.stringify({ - jsonrpc: "2.0", - id: parsed.id, - result: { - action: "cancel" - } - }) - ); - setElicitationRequest(null); - setFormData({}); - } - }); - } - } catch { - // If parsing fails, let the default handler deal with it - console.log("Non-elicitation message:", message.data); - } - } - }); - - const addLocalServer = () => { - const serverUrl = `${window.location.origin}/mcp-server`; - const serverName = "Local Demo Server"; - - agentFetch( - { - agent: "MyAgent", - host: agent.host, - name: sessionId, - path: "add-mcp" - }, - { - body: JSON.stringify({ name: serverName, url: serverUrl }), - method: "POST" - } - ); - }; - - const callTool = async (toolName: string, serverId: string) => { - try { - let args: Record = {}; - - // Set default arguments for tools - if (toolName === "increment-counter") { - args = { amount: 1 }; - } else if (toolName === "create-user") { - const username = prompt("Enter username:") || "testuser"; - args = { username }; - } - - console.log( - `Calling tool ${toolName} on server ${serverId} with args:`, - args - ); - - // Call the real MCP tool through the agent - const result = await agent.call("callMcpTool", [ - serverId, - toolName, - args - ]); - console.log("Tool result:", result); - - // Add result to display - setToolResults((prev) => [ - { - tool: toolName, - result: JSON.stringify(result, null, 2), - timestamp: Date.now() - }, - ...prev.slice(0, 4) - ]); - } catch (error) { - console.error("Error calling tool:", error); - - // Show error in results - setToolResults((prev) => [ - { - tool: toolName, - result: JSON.stringify( - { error: error instanceof Error ? error.message : String(error) }, - null, - 2 - ), - timestamp: Date.now() - }, - ...prev.slice(0, 4) - ]); - } - }; - - return ( -
-
-

MCP Elicitation Demo

-
-
- {isConnected ? "Connected" : "Disconnected"} -
-
- -
-

Connected Servers ({Object.keys(mcpState.servers).length})

- {Object.entries(mcpState.servers).map(([id, server]) => ( -
-
- {server.name} - {server.server_url} -
-
-
- {server.state} -
- {server.state === "authenticating" && server.auth_url && ( - - )} -
- ))} -
- - {mcpState.tools.length > 0 && ( -
-

Available Tools ({mcpState.tools.length})

- {mcpState.tools.map((tool) => ( -
-
- {tool.name} - -
-

{tool.description}

-
- ))} -
- )} - - {mcpState.resources.length > 0 && ( -
-

Resources ({mcpState.resources.length})

- {mcpState.resources.map((resource) => ( -
- {resource.name} - {resource.uri} -
- ))} -
- )} - - {toolResults.length > 0 && ( -
-

Tool Results

- {toolResults.map((result) => ( -
-
- {result.tool} - - {new Date(result.timestamp).toLocaleTimeString()} - -
-
{result.result}
-
- ))} -
- )} - - {/* Elicitation Modal */} - {elicitationRequest && ( -
-
-
-

{elicitationRequest.message}

-

- Requested by: MCP Demo Server -

-
-
{ - e.preventDefault(); - elicitationRequest.resolve(formData); - setElicitationRequest(null); - setFormData({}); - }} - > - {Object.entries(elicitationRequest.schema.properties || {}).map( - ([key, prop]: [ - string, - { - type: string; - title?: string; - description?: string; - format?: string; - enum?: string[]; - enumNames?: string[]; - } - ]) => ( -
- - {prop.description && ( -

{prop.description}

- )} - - {prop.type === "boolean" ? ( - - setFormData((prev) => ({ - ...prev, - [key]: e.target.checked - })) - } - /> - ) : prop.enum ? ( - - ) : ( - - setFormData((prev) => ({ - ...prev, - [key]: e.target.value - })) - } - required={elicitationRequest.schema.required?.includes( - key - )} - /> - )} -
- ) - )} - -
- - - -
-
-
-
- )} -
- ); -} - -const root = createRoot(document.getElementById("root")!); -root.render(); diff --git a/examples/mcp-elicitation-demo/src/server.ts b/examples/mcp-elicitation-demo/src/server.ts deleted file mode 100644 index 8d6ba1ad..00000000 --- a/examples/mcp-elicitation-demo/src/server.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpAgent, type ElicitResult } from "agents/mcp"; -import { - Agent, - type AgentNamespace, - routeAgentRequest, - callable, - type Connection, - type WSMessage -} from "agents"; -import { z } from "zod"; - -type Env = { - MyAgent: AgentNamespace; - McpServerAgent: DurableObjectNamespace; - HOST: string; -}; - -export class McpServerAgent extends McpAgent { - server = new McpServer({ - name: "Elicitation Demo Server", - version: "1.0.0" - }); - - initialState = { counter: 0 }; - - // Track active session for cross-agent elicitation - private activeSession: string | null = null; - - async elicitInput(params: { - message: string; - requestedSchema: { - type: string; - properties?: Record< - string, - { - type: string; - title?: string; - description?: string; - format?: string; - enum?: string[]; - enumNames?: string[]; - } - >; - required?: string[]; - }; - }): Promise { - if (!this.activeSession) { - throw new Error("No active client session found for elicitation"); - } - - // Get the MyAgent instance that handles browser communication - const myAgentId = this.env.MyAgent.idFromName(this.activeSession); - const myAgent = this.env.MyAgent.get(myAgentId); - - // Create MCP-compliant elicitation request - const requestId = `elicit_${Math.random().toString(36).substring(2, 11)}`; - const elicitRequest = { - jsonrpc: "2.0" as const, - id: requestId, - method: "elicitation/create", - params: { - message: params.message, - requestedSchema: params.requestedSchema - } - }; - - // Forward request to MyAgent which communicates with browser - const response = await myAgent.fetch( - new Request("https://internal/elicit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(elicitRequest) - }) - ); - - if (!response.ok) { - throw new Error("Failed to send elicitation request"); - } - - return (await response.json()) as ElicitResult; - } - - async init() { - // Counter tool with user confirmation via elicitation - this.server.tool( - "increment-counter", - "Increment the counter with user confirmation", - { - amount: z.number().describe("Amount to increment by").default(1), - __clientSession: z - .string() - .optional() - .describe("Internal client session ID") - }, - async ({ - amount, - __clientSession - }: { - amount: number; - __clientSession?: string; - }) => { - // Store session for cross-agent elicitation - if (__clientSession) { - this.activeSession = __clientSession; - } - - // Request user confirmation via elicitation - const confirmation = await this.elicitInput({ - message: `Are you sure you want to increment the counter by ${amount}?`, - requestedSchema: { - type: "object", - properties: { - confirmed: { - type: "boolean", - title: "Confirm increment", - description: "Check to confirm the increment" - } - }, - required: ["confirmed"] - } - }); - - if ( - confirmation.action === "accept" && - confirmation.content?.confirmed - ) { - this.setState({ - counter: this.state.counter + amount - }); - - return { - content: [ - { - type: "text", - text: `Counter incremented by ${amount}. New value: ${this.state.counter}` - } - ] - }; - } else { - return { - content: [ - { - type: "text", - text: "Counter increment cancelled." - } - ] - }; - } - } - ); - - // User creation tool with form-based elicitation - this.server.tool( - "create-user", - "Create a new user with form input", - { - username: z.string().describe("Username for the new user"), - __clientSession: z - .string() - .optional() - .describe("Internal client session ID") - }, - async ({ - username, - __clientSession - }: { - username: string; - __clientSession?: string; - }) => { - // Store session for cross-agent elicitation - if (__clientSession) { - this.activeSession = __clientSession; - } - - // Request user details via elicitation - const userInfo = await this.elicitInput({ - message: `Create user account for "${username}":`, - requestedSchema: { - type: "object", - properties: { - email: { - type: "string", - format: "email", - title: "Email Address", - description: "User's email address" - }, - role: { - type: "string", - title: "Role", - enum: ["viewer", "editor", "admin"], - enumNames: ["Viewer", "Editor", "Admin"] - }, - sendWelcome: { - type: "boolean", - title: "Send Welcome Email", - description: "Send welcome email to user" - } - }, - required: ["email", "role"] - } - }); - - if (userInfo.action === "accept" && userInfo.content) { - const details = userInfo.content; - return { - content: [ - { - type: "text", - text: `User created:\n• Username: ${username}\n• Email: ${details.email}\n• Role: ${details.role}\n• Welcome email: ${details.sendWelcome ? "Yes" : "No"}` - } - ] - }; - } else { - return { - content: [ - { - type: "text", - text: "User creation cancelled." - } - ] - }; - } - } - ); - - // Counter resource - this.server.resource("counter", "mcp://resource/counter", (uri: URL) => { - return { - contents: [ - { - text: `Current counter value: ${this.state.counter}`, - uri: uri.href - } - ] - }; - }); - } - - async onRequest(request: Request): Promise { - const reqUrl = new URL(request.url); - - // Handle session storage for cross-agent elicitation - if ( - (reqUrl.pathname.endsWith("store-session") || - reqUrl.hostname === "internal") && - request.method === "POST" - ) { - const { sessionId } = (await request.json()) as { sessionId: string }; - this.activeSession = sessionId; - return new Response("Ok", { status: 200 }); - } - - return new Response("Not found", { status: 404 }); - } - - onStateUpdate(_state: { counter: number }) { - // Override to handle state updates if needed - } -} - -export class MyAgent extends Agent { - async onRequest(request: Request): Promise { - const reqUrl = new URL(request.url); - - // Handle MCP server registration - if (reqUrl.pathname.endsWith("add-mcp") && request.method === "POST") { - const mcpServer = (await request.json()) as { url: string; name: string }; - await this.addMcpServer(mcpServer.name, mcpServer.url, this.env.HOST); - return new Response("Ok", { status: 200 }); - } - - // Health check endpoint - if (reqUrl.pathname.endsWith("ping") && request.method === "GET") { - return new Response("pong", { status: 200 }); - } - - // Handle elicitation forwarding from McpServerAgent to browser - if (reqUrl.pathname.endsWith("elicit") && request.method === "POST") { - const elicitRequest = (await request.json()) as { - id: string; - method: string; - params: { - message: string; - requestedSchema: Record; - }; - }; - - // Broadcast elicitation request to connected browser clients - this.broadcast(JSON.stringify(elicitRequest)); - - // Set up response handling for this specific request - const elicitationResolvers = new Map< - string, - (result: ElicitResult) => void - >(); - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - elicitationResolvers.delete(elicitRequest.id); - resolve( - new Response( - JSON.stringify({ - action: "cancel", - content: {} - } as ElicitResult), - { - status: 200, - headers: { "Content-Type": "application/json" } - } - ) - ); - }, 60000); - - // Store resolver for this request ID - elicitationResolvers.set(elicitRequest.id, (result: ElicitResult) => { - clearTimeout(timeout); - elicitationResolvers.delete(elicitRequest.id); - resolve( - new Response(JSON.stringify(result), { - status: 200, - headers: { "Content-Type": "application/json" } - }) - ); - }); - - // Make resolvers accessible to onMessage handler - ( - this as { - _elicitationResolvers?: Map void>; - } - )._elicitationResolvers = elicitationResolvers; - }); - } - - return new Response("Not found", { status: 404 }); - } - - /** - * Handle incoming messages from browser clients - */ - async onMessage( - _connection: Connection, - message: WSMessage - ): Promise { - try { - const messageData = - typeof message === "string" ? message : message.toString(); - - const data = JSON.parse(messageData) as { - id?: string; - result?: ElicitResult; - }; - - // Check if this is an elicitation response - if (data.id && data.result) { - const elicitationResolvers = ( - this as { - _elicitationResolvers?: Map void>; - } - )._elicitationResolvers; - if (elicitationResolvers?.has(data.id)) { - const resolver = elicitationResolvers.get(data.id); - if (resolver) { - resolver(data.result); - } - return; - } - } - } catch { - // Not an elicitation response or parsing failed, ignore - } - } - - /** - * RPC method to call MCP tools with session tracking - */ - @callable() - async callMcpTool( - serverId: string, - toolName: string, - args: Record - ): Promise { - try { - // Inject session ID for cross-agent elicitation - const enhancedArgs = { - ...args, - __clientSession: this.name - }; - - const result = await this.mcp.callTool({ - serverId, - name: toolName, - arguments: enhancedArgs - }); - - return result; - } catch (error) { - console.error("Error calling MCP tool:", error); - throw error; - } - } -} - -// Create a direct MCP server export for /mcp-server path -export const mcpServer = McpServerAgent.serve("/mcp-server", { - binding: "McpServerAgent" -}); - -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { - const url = new URL(request.url); - - // Route MCP server requests to the dedicated MCP server - if (url.pathname.startsWith("/mcp-server")) { - // Handle session setting for cross-agent elicitation - if (url.pathname.endsWith("/set-session") && request.method === "POST") { - const { sessionId } = (await request.json()) as { sessionId: string }; - const mcpServerAgentId = env.McpServerAgent.idFromName("default"); - const mcpServerAgent = env.McpServerAgent.get(mcpServerAgentId); - - await mcpServerAgent.fetch( - new Request("https://internal/set-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId }) - }) - ); - - return new Response("Ok", { status: 200 }); - } - - (ctx as { props?: Record }).props = {}; - return mcpServer.fetch(request, env, ctx); - } - - // Route other requests to browser-facing agent - return ( - (await routeAgentRequest(request, env, { cors: true })) || - new Response("Not found", { status: 404 }) - ); - } -} satisfies ExportedHandler; diff --git a/examples/mcp-elicitation-demo/src/styles.css b/examples/mcp-elicitation-demo/src/styles.css deleted file mode 100644 index 9f7768c5..00000000 --- a/examples/mcp-elicitation-demo/src/styles.css +++ /dev/null @@ -1,384 +0,0 @@ -/* Global Styles */ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif; - line-height: 1.6; - color: #333; - background-color: #f5f5f5; -} - -.container { - max-width: 1200px; - margin: 0 auto; - padding: 20px; -} - -/* Header */ -.header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 30px; - padding: 20px; - background: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -h1 { - color: #2c3e50; - font-size: 2.5rem; - font-weight: 300; -} - -h2 { - color: #34495e; - margin-bottom: 15px; - font-size: 1.5rem; - font-weight: 500; -} - -/* Status Indicator */ -.status-indicator { - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; -} - -.status-dot { - width: 12px; - height: 12px; - border-radius: 50%; - background-color: #e74c3c; - transition: background-color 0.3s ease; -} - -.status-dot.connected { - background-color: #27ae60; -} - -/* Sections */ -.section { - background: white; - margin-bottom: 20px; - padding: 25px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -/* Forms */ -.mcp-form { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.mcp-input { - flex: 1; - min-width: 200px; - padding: 12px; - border: 2px solid #ddd; - border-radius: 6px; - font-size: 14px; - transition: border-color 0.3s ease; -} - -.mcp-input:focus { - outline: none; - border-color: #3498db; -} - -/* Buttons */ -button { - padding: 12px 20px; - border: none; - border-radius: 6px; - background-color: #3498db; - color: white; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.3s ease; -} - -button:hover { - background-color: #2980b9; -} - -.add-local-btn { - background-color: #27ae60; - padding: 15px 25px; - font-size: 16px; - margin-bottom: 10px; -} - -.add-local-btn:hover { - background-color: #229954; -} - -.auth-btn { - background-color: #f39c12; - font-size: 12px; - padding: 8px 12px; -} - -.auth-btn:hover { - background-color: #e67e22; -} - -/* Help Text */ -.help-text { - color: #7f8c8d; - font-size: 14px; - margin-top: 5px; -} - -/* Server Items */ -.server-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 15px; - margin-bottom: 10px; - background-color: #f8f9fa; - border-radius: 6px; - border-left: 4px solid #3498db; -} - -.server-info { - display: flex; - flex-direction: column; - gap: 4px; -} - -.server-url { - color: #7f8c8d; - font-size: 12px; - font-family: "Monaco", "Menlo", monospace; -} - -/* Tool and Resource Items */ -.tool-item, -.resource-item { - padding: 15px; - margin-bottom: 10px; - background-color: #f8f9fa; - border-radius: 6px; - border-left: 4px solid #27ae60; -} - -.tool-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.call-tool-btn { - background-color: #e74c3c; - font-size: 12px; - padding: 8px 12px; -} - -.call-tool-btn:hover { - background-color: #c0392b; -} - -.tool-item p { - color: #7f8c8d; - margin-top: 5px; - font-size: 14px; -} - -.resource-uri { - color: #7f8c8d; - font-size: 12px; - font-family: "Monaco", "Menlo", monospace; - margin-left: 10px; -} - -/* Tool Results */ -.result-item { - padding: 15px; - margin-bottom: 10px; - background-color: #f8f9fa; - border-radius: 6px; - border-left: 4px solid #3498db; -} - -.result-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.result-time { - color: #7f8c8d; - font-size: 12px; -} - -.result-content { - background-color: #2c3e50; - color: #ecf0f1; - padding: 10px; - border-radius: 4px; - font-family: "Monaco", "Menlo", monospace; - font-size: 12px; - overflow-x: auto; - white-space: pre-wrap; -} - -/* Elicitation Modal */ -.elicitation-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; -} - -.elicitation-modal { - background: white; - padding: 30px; - border-radius: 12px; - max-width: 500px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); -} - -.elicitation-header { - margin-bottom: 20px; -} - -.elicitation-modal h3 { - margin: 0 0 8px 0; - color: #2c3e50; - font-size: 1.4rem; -} - -.elicitation-source { - margin: 0; - font-size: 0.9em; - color: #7f8c8d; - font-style: italic; -} - -/* Form Fields */ -.form-field { - margin-bottom: 20px; -} - -.form-field label { - display: block; - margin-bottom: 5px; - font-weight: 500; - color: #34495e; -} - -.field-description { - font-size: 12px; - color: #7f8c8d; - margin-bottom: 8px; -} - -.form-field input, -.form-field select { - width: 100%; - padding: 10px; - border: 2px solid #ddd; - border-radius: 6px; - font-size: 14px; - transition: border-color 0.3s ease; -} - -.form-field input:focus, -.form-field select:focus { - outline: none; - border-color: #3498db; -} - -.form-field input[type="checkbox"] { - width: auto; - margin-right: 8px; -} - -/* Form Actions */ -.form-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 25px; - padding-top: 20px; - border-top: 1px solid #eee; -} - -.cancel-btn { - background-color: #95a5a6; -} - -.cancel-btn:hover { - background-color: #7f8c8d; -} - -.decline-btn { - background-color: #e67e22; -} - -.decline-btn:hover { - background-color: #d35400; -} - -.submit-btn { - background-color: #27ae60; -} - -.submit-btn:hover { - background-color: #229954; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .container { - padding: 10px; - } - - .header { - flex-direction: column; - gap: 15px; - text-align: center; - } - - h1 { - font-size: 2rem; - } - - .mcp-form { - flex-direction: column; - } - - .server-item { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - - .elicitation-modal { - margin: 20px; - width: calc(100% - 40px); - } -} diff --git a/examples/mcp-elicitation-demo/vite.config.ts b/examples/mcp-elicitation-demo/vite.config.ts deleted file mode 100644 index 08c0388e..00000000 --- a/examples/mcp-elicitation-demo/vite.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { cloudflare } from "@cloudflare/vite-plugin"; -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [ - react(), - cloudflare({ - inspectorPort: 9241 - }) - ] -}); diff --git a/examples/mcp-elicitation/README.md b/examples/mcp-elicitation/README.md new file mode 100644 index 00000000..92809f60 --- /dev/null +++ b/examples/mcp-elicitation/README.md @@ -0,0 +1,3 @@ +# MCP Elicitation Demo + +This is a MCP server example that shows how to use elicitation support using the Agents SDK. diff --git a/examples/mcp-elicitation/package.json b/examples/mcp-elicitation/package.json new file mode 100644 index 00000000..9abe4189 --- /dev/null +++ b/examples/mcp-elicitation/package.json @@ -0,0 +1,11 @@ +{ + "author": "Matt Carey ", + "keywords": [], + "name": "@cloudflare/agents-mcp-elicitation", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev" + }, + "type": "module" +} diff --git a/examples/mcp-elicitation-demo/public/favicon.ico b/examples/mcp-elicitation/public/favicon.ico similarity index 100% rename from examples/mcp-elicitation-demo/public/favicon.ico rename to examples/mcp-elicitation/public/favicon.ico diff --git a/examples/mcp-elicitation/src/index.ts b/examples/mcp-elicitation/src/index.ts new file mode 100644 index 00000000..689e5efa --- /dev/null +++ b/examples/mcp-elicitation/src/index.ts @@ -0,0 +1,132 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + createMcpHandler, + type TransportState, + WorkerTransport +} from "agents/mcp"; +import * as z from "zod"; +import { Agent, getAgentByName } from "agents"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker-provider.js"; + +const STATE_KEY = "mcp_transport_state"; + +type Env = { + MyAgent: DurableObjectNamespace; +}; + +interface State { + counter: number; +} + +export class MyAgent extends Agent { + server = new McpServer( + { + name: "test", + version: "1.0.0" + }, + { + jsonSchemaValidator: new CfWorkerJsonSchemaValidator() + } + ); + + transport = new WorkerTransport({ + sessionIdGenerator: () => this.name, + storage: { + get: () => { + return this.ctx.storage.kv.get(STATE_KEY); + }, + set: (state: TransportState) => { + this.ctx.storage.kv.put(STATE_KEY, state); + } + } + }); + + initialState = { + counter: 0 + }; + + onStart(): void | Promise { + this.server.registerTool( + "increase-counter", + { + description: "Increase the counter", + inputSchema: { + confirm: z.boolean().describe("Do you want to increase the counter?") + } + }, + async ({ confirm }) => { + if (!confirm) { + return { + content: [{ type: "text", text: "Counter increase cancelled." }] + }; + } + try { + const basicInfo = await this.server.server.elicitInput({ + message: "By how much do you want to increase the counter?", + requestedSchema: { + type: "object", + properties: { + amount: { + type: "number", + title: "Amount", + description: "The amount to increase the counter by", + minLength: 1 + } + }, + required: ["amount"] + } + }); + + if (basicInfo.action !== "accept" || !basicInfo.content) { + return { + content: [{ type: "text", text: "Counter increase cancelled." }] + }; + } + + if (basicInfo.content.amount && Number(basicInfo.content.amount)) { + this.setState({ + ...this.state, + counter: this.state.counter + Number(basicInfo.content.amount) + }); + + return { + content: [ + { + type: "text", + text: `Counter increased by ${basicInfo.content.amount}, current value is ${this.state.counter}` + } + ] + }; + } + + return { + content: [ + { type: "text", text: "Counter increase failed, invalid amount." } + ] + }; + } catch (error) { + console.log(error); + + return { + content: [{ type: "text", text: "Counter increase failed." }] + }; + } + } + ); + } + + async onMcpRequest(request: Request) { + return createMcpHandler(this.server, { + transport: this.transport + })(request, this.env, {} as ExecutionContext); + } +} + +export default { + async fetch(request: Request, env: Env, _ctx: ExecutionContext) { + const sessionId = + request.headers.get("mcp-session-id") ?? crypto.randomUUID(); + const agent = await getAgentByName(env.MyAgent, sessionId); + return await agent.onMcpRequest(request); + } +}; diff --git a/examples/mcp-elicitation-demo/tsconfig.json b/examples/mcp-elicitation/tsconfig.json similarity index 100% rename from examples/mcp-elicitation-demo/tsconfig.json rename to examples/mcp-elicitation/tsconfig.json diff --git a/examples/mcp-elicitation-demo/wrangler.jsonc b/examples/mcp-elicitation/wrangler.jsonc similarity index 56% rename from examples/mcp-elicitation-demo/wrangler.jsonc rename to examples/mcp-elicitation/wrangler.jsonc index a504941f..b504cbb7 100644 --- a/examples/mcp-elicitation-demo/wrangler.jsonc +++ b/examples/mcp-elicitation/wrangler.jsonc @@ -1,7 +1,4 @@ { - "assets": { - "directory": "dist/client" - }, "compatibility_date": "2025-03-14", "compatibility_flags": ["nodejs_compat"], "durable_objects": { @@ -9,17 +6,13 @@ { "class_name": "MyAgent", "name": "MyAgent" - }, - { - "class_name": "McpServerAgent", - "name": "McpServerAgent" } ] }, - "main": "src/server.ts", + "main": "src/index.ts", "migrations": [ { - "new_sqlite_classes": ["MyAgent", "McpServerAgent"], + "new_sqlite_classes": ["MyAgent"], "tag": "v1" } ], @@ -28,8 +21,5 @@ "logs": { "enabled": true } - }, - "vars": { - "HOST": "" } } diff --git a/openai-sdk/chess-app/src/index.ts b/openai-sdk/chess-app/src/index.ts index 09e2ca61..206d7927 100644 --- a/openai-sdk/chess-app/src/index.ts +++ b/openai-sdk/chess-app/src/index.ts @@ -45,9 +45,11 @@ server.registerTool( "openai/toolInvocation/invoked": "Chess widget opened" } }, - async (_, _extra) => { + async () => { return { - content: [{ type: "text", text: "Successfully rendered chess game menu" }] + content: [ + { type: "text" as const, text: "Successfully rendered chess game menu" } + ] }; } ); diff --git a/package-lock.json b/package-lock.json index c71ce30e..bbf89073 100644 --- a/package-lock.json +++ b/package-lock.json @@ -466,8 +466,12 @@ "node": "^18 || >=20" } }, + "examples/mcp-elicitation": { + "name": "@cloudflare/agents-mcp-elicitation" + }, "examples/mcp-elicitation-demo": { "name": "@cloudflare/agents-mcp-elicitation-demo", + "extraneous": true, "dependencies": { "nanoid": "^5.1.6", "react": "^19.2.0", @@ -475,24 +479,6 @@ "zod": "^3.25.76" } }, - "examples/mcp-elicitation-demo/node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "examples/mcp-worker": { "name": "@cloudflare/agents-mcp-worker", "version": "0.0.1" @@ -1223,7 +1209,6 @@ "integrity": "sha512-oT/K4YWNhmwpVmGeaHNmF7mLRfgjszlVr7lJtpS4jx5khmxmMzWZEEQRrJEpgzeHP6DOq9qWLPNT0bjMK7TchQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -1447,7 +1432,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -1614,7 +1598,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2144,6 +2127,12 @@ "node": ">=18" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@changesets/apply-release-plan": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.13.tgz", @@ -2482,8 +2471,8 @@ "resolved": "examples/mcp-client", "link": true }, - "node_modules/@cloudflare/agents-mcp-elicitation-demo": { - "resolved": "examples/mcp-elicitation-demo", + "node_modules/@cloudflare/agents-mcp-elicitation": { + "resolved": "examples/mcp-elicitation", "link": true }, "node_modules/@cloudflare/agents-mcp-example": { @@ -2591,7 +2580,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -2729,8 +2717,7 @@ "version": "4.20251106.1", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251106.1.tgz", "integrity": "sha512-+oD+w4K9VYMb1/d3lQ4A7GIV7Im6xpwaCRAXSUSeYKWw5LSQSpai1kJXh7LGIVE9HnJv9gXKpjaMSgcffW4Igw==", - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@coinbase/cdp-sdk": { "version": "1.38.2", @@ -2858,7 +2845,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -4944,7 +4930,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -5092,7 +5077,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -5306,7 +5290,6 @@ "integrity": "sha512-afVZc4/ev/jpMDmMHH8DYV0m+b8mEhRuwC0qog14untuxPq6FJFOUG8J6UnKKDf4vXsnODyYbOoXT+qkXGJy/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@openai/agents-core": "0.3.0", "@openai/agents-openai": "0.3.0", @@ -5423,6 +5406,7 @@ "integrity": "sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -5876,7 +5860,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6316,7 +6299,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6684,7 +6666,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6714,6 +6695,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6731,6 +6713,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6748,6 +6731,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6765,6 +6749,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6782,6 +6767,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6799,6 +6785,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6816,6 +6803,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6833,6 +6821,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6850,6 +6839,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6867,6 +6857,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6881,6 +6872,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^1.0.7" }, @@ -6901,6 +6893,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6918,6 +6911,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6935,6 +6929,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -7703,7 +7698,6 @@ "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-2.3.0.tgz", "integrity": "sha512-sb6PgwoW2LjE5oTFu4lhlS/cGt/NB3YrShEyx7JgWFWysfgLdJnhwWThgwy/4HjNsmtMrQGWVls0yVBHcMvlMQ==", "license": "MIT", - "peer": true, "dependencies": { "@solana/accounts": "2.3.0", "@solana/addresses": "2.3.0", @@ -8379,7 +8373,6 @@ "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-2.3.0.tgz", "integrity": "sha512-LvjADZrpZ+CnhlHqfI5cmsRzX9Rpyb1Ox2dMHnbsRNzeKAMhu9w4ZBIaeTdO322zsTr509G1B+k2ABD3whvUBA==", "license": "MIT", - "peer": true, "dependencies": { "@solana/accounts": "2.3.0", "@solana/codecs": "2.3.0", @@ -8484,7 +8477,6 @@ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", @@ -8859,6 +8851,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.89.0.tgz", "integrity": "sha512-joFV1MuPhSLsKfTzwjmPDrp8ENfZ9N23ymFu07nLfn3JCkSHy0CFgsyhHTJOmWaumC/WiNIKM0EJyduCF/Ih/Q==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -8887,7 +8880,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9145,7 +9137,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -9155,7 +9146,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9278,7 +9268,6 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -9372,7 +9361,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -9388,7 +9376,6 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -9460,7 +9447,6 @@ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-2.21.1.tgz", "integrity": "sha512-uG0Cujm24acrFYqbi1RGw9MRMLTGVKvyv5OAJT+2pkcasM9PP8eJCzhlvUxK9IYVXXnbaAT8JX/rXEurOSM6mg==", "license": "MIT", - "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -10213,7 +10199,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -10650,7 +10635,6 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.15.4.tgz", "integrity": "sha512-0g/68hLHEJZF2nYUcZM5O0kOnzCsCIf8eA9+0jfBAxp4ycujrIHRgIOdZCFKL9GoTsn8AypWbziypH5aEIF+aA==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/internal-helpers": "0.7.4", @@ -11463,7 +11447,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -11855,7 +11838,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", @@ -11909,7 +11891,6 @@ "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -12692,7 +12673,6 @@ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "license": "MIT", - "peer": true, "dependencies": { "node-fetch": "^2.7.0" } @@ -13212,7 +13192,6 @@ "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.15.tgz", "integrity": "sha512-r6kEJXDKecVOCj2nLMuXK/FCPeurW33+3JRpfXVbjLja3XUYFfD9I/JBreH6sUyzcm3G/YQboBjMla6poKeSdA==", "license": "MIT", - "peer": true, "dependencies": { "@ecies/ciphers": "^0.2.3", "@noble/ciphers": "^1.3.0", @@ -13853,8 +13832,7 @@ "version": "6.4.9", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eventemitter3": { "version": "5.0.1", @@ -14793,8 +14771,7 @@ "version": "3.13.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/h3": { "version": "1.15.4", @@ -15674,7 +15651,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -15977,7 +15953,6 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -16014,6 +15989,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16054,6 +16030,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16074,6 +16051,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16094,6 +16072,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16114,6 +16093,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16134,6 +16114,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16174,6 +16155,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16194,6 +16176,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -16214,6 +16197,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19135,7 +19119,6 @@ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.56.1" }, @@ -19700,7 +19683,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -19728,7 +19710,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19812,7 +19793,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -20173,6 +20153,7 @@ "integrity": "sha512-FYUbq0StVHOjkR/hEJ667Pup3ugeB9odBcbmxU5il9QfT9X2t/FPhkqFYQthbYxD2bKnQyO+2vHTgnmOHwZdeA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.96.0", "@rolldown/pluginutils": "1.0.0-beta.46" @@ -20260,14 +20241,14 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.46.tgz", "integrity": "sha512-xMNwJo/pHkEP/mhNVnW+zUiJDle6/hxrwO0mfSJuEVRbBfgrJFuUSRoZx/nYUw5pCjrysl9OkNXCkAdih8GCnA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/rollup": { "version": "4.46.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.4.tgz", "integrity": "sha512-YbxoxvoqNg9zAmw4+vzh1FkGAiZRK+LhnSrbSrSXMdZYsRPDWoshcSd/pldKRO6lWzv/e9TiJAVQyirYIeSIPQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -21064,7 +21045,6 @@ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "license": "MIT", - "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", @@ -22228,7 +22208,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -22335,7 +22314,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22420,7 +22398,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.21.tgz", "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", "license": "MIT", - "peer": true, "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.7", @@ -22821,7 +22798,6 @@ "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -22832,7 +22808,6 @@ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -22909,7 +22884,6 @@ "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz", "integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==", "license": "MIT", - "peer": true, "dependencies": { "derive-valtio": "0.1.0", "proxy-compare": "2.6.0", @@ -23002,7 +22976,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -23063,7 +23036,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -23224,7 +23196,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -23536,7 +23507,6 @@ "integrity": "sha512-8D1UmsxrRr3Go7enbYCsYoiWeGn66u1WFNojPSgtjp7z8pV2cXskjr05vQ1OOzl7+rg1hDDofnCJqVwChMym8g==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -23650,7 +23620,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -23777,7 +23746,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -23914,7 +23882,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "devOptional": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -24049,7 +24016,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -24177,6 +24143,7 @@ "license": "MIT", "dependencies": { "@ai-sdk/openai": "2.0.64", + "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "^1.21.0", "ai": "5.0.89", "cron-schedule": "^5.0.4", diff --git a/packages/agents/package.json b/packages/agents/package.json index 002d8e22..0f00601f 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@ai-sdk/openai": "2.0.64", + "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "^1.21.0", "ai": "5.0.89", "cron-schedule": "^5.0.4", diff --git a/packages/agents/src/mcp/handler.ts b/packages/agents/src/mcp/handler.ts index a79cb393..b92bf9ee 100644 --- a/packages/agents/src/mcp/handler.ts +++ b/packages/agents/src/mcp/handler.ts @@ -5,7 +5,6 @@ import { type WorkerTransportOptions } from "./worker-transport"; import { runWithAuthContext, type McpAuthContext } from "./auth-context"; -import type { CORSOptions } from "./types"; export interface CreateMcpHandlerOptions extends WorkerTransportOptions { /** @@ -15,26 +14,17 @@ export interface CreateMcpHandlerOptions extends WorkerTransportOptions { */ route?: string; /** - * CORS configuration options for handling cross-origin requests. - * These options are passed to the WorkerTransport which handles adding - * CORS headers to all responses. - * - * Default values are: - * - origin: "*" - * - headers: "Content-Type, Accept, Authorization, mcp-session-id, MCP-Protocol-Version" - * - methods: "GET, POST, DELETE, OPTIONS" - * - exposeHeaders: "mcp-session-id" - * - maxAge: 86400 - * - * Provided options will overwrite the defaults. + * An optional auth context to use for handling MCP requests. + * If not provided, the handler will look for props in the execution context. */ - corsOptions?: CORSOptions; + authContext?: McpAuthContext; + /** + * An optional transport to use for handling MCP requests. + * If not provided, a WorkerTransport will be created with the provided WorkerTransportOptions. + */ + transport?: WorkerTransport; } -export type OAuthExecutionContext = ExecutionContext & { - props?: Record; -}; - export function createMcpHandler( server: McpServer | Server, options: CreateMcpHandlerOptions = {} @@ -50,24 +40,45 @@ export function createMcpHandler( _env: unknown, ctx: ExecutionContext ): Promise => { - // Check if the request path matches the configured route const url = new URL(request.url); if (route && url.pathname !== route) { return new Response("Not Found", { status: 404 }); } - const oauthCtx = ctx as OAuthExecutionContext; - const authContext: McpAuthContext | undefined = oauthCtx.props - ? { props: oauthCtx.props } - : undefined; + const transport = + options.transport ?? + new WorkerTransport({ + sessionIdGenerator: options.sessionIdGenerator, + enableJsonResponse: options.enableJsonResponse, + onsessioninitialized: options.onsessioninitialized, + corsOptions: options.corsOptions, + storage: options.storage + }); - const transport = new WorkerTransport(options); - await server.connect(transport); + const buildAuthContext = () => { + if (options.authContext) { + return options.authContext; + } + + if (ctx.props && Object.keys(ctx.props).length > 0) { + return { + props: ctx.props as Record + }; + } + + return undefined; + }; const handleRequest = async () => { return await transport.handleRequest(request); }; + const authContext = buildAuthContext(); + + if (!transport.started) { + await server.connect(transport); + } + try { if (authContext) { return await runWithAuthContext(authContext, handleRequest); diff --git a/packages/agents/src/mcp/index.ts b/packages/agents/src/mcp/index.ts index fc9d3fce..d7b8e629 100644 --- a/packages/agents/src/mcp/index.ts +++ b/packages/agents/src/mcp/index.ts @@ -452,13 +452,13 @@ export type { export { createMcpHandler, experimental_createMcpHandler, - type CreateMcpHandlerOptions, - type OAuthExecutionContext + type CreateMcpHandlerOptions } from "./handler"; export { getMcpAuthContext, type McpAuthContext } from "./auth-context"; export { WorkerTransport, - type WorkerTransportOptions + type WorkerTransportOptions, + type TransportState } from "./worker-transport"; diff --git a/packages/agents/src/mcp/worker-transport.ts b/packages/agents/src/mcp/worker-transport.ts index f146c9d7..8bf60fde 100644 --- a/packages/agents/src/mcp/worker-transport.ts +++ b/packages/agents/src/mcp/worker-transport.ts @@ -19,9 +19,8 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import type { CORSOptions } from "./types"; -// MCP Protocol Version constants const SUPPORTED_PROTOCOL_VERSIONS = ["2025-03-26", "2025-06-18"] as const; -const DEFAULT_PROTOCOL_VERSION = "2025-03-26"; // For backwards compatibility +const DEFAULT_PROTOCOL_VERSION = "2025-03-26"; const MCP_PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"; type ProtocolVersion = (typeof SUPPORTED_PROTOCOL_VERSIONS)[number]; @@ -33,15 +32,35 @@ interface StreamMapping { cleanup: () => void; } +export interface MCPStorageApi { + get(): Promise | TransportState | undefined; + set(state: TransportState): Promise | void; +} + +export interface TransportState { + sessionId?: string; + initialized: boolean; + protocolVersion?: ProtocolVersion; +} + export interface WorkerTransportOptions { sessionIdGenerator?: () => string; + /** + * Enable traditional Request/Response mode, this will disable streaming. + */ enableJsonResponse?: boolean; onsessioninitialized?: (sessionId: string) => void; corsOptions?: CORSOptions; + /** + * Optional storage api for persisting transport state. + * Use this to store session state in Durable Object/Agent storage + * so it survives hibernation/restart. + */ + storage?: MCPStorageApi; } export class WorkerTransport implements Transport { - private started = false; + started = false; private initialized = false; private sessionIdGenerator?: () => string; private enableJsonResponse = false; @@ -52,6 +71,8 @@ export class WorkerTransport implements Transport { private requestResponseMap = new Map(); private corsOptions?: CORSOptions; private protocolVersion?: ProtocolVersion; + private storage?: MCPStorageApi; + private stateRestored = false; sessionId?: string; onclose?: () => void; @@ -63,6 +84,44 @@ export class WorkerTransport implements Transport { this.enableJsonResponse = options?.enableJsonResponse ?? false; this.onsessioninitialized = options?.onsessioninitialized; this.corsOptions = options?.corsOptions; + this.storage = options?.storage; + } + + /** + * Restore transport state from persistent storage. + * This is automatically called on start. + */ + private async restoreState() { + if (!this.storage || this.stateRestored) { + return; + } + + const state = await Promise.resolve(this.storage.get()); + + if (state) { + this.sessionId = state.sessionId; + this.initialized = state.initialized; + this.protocolVersion = state.protocolVersion; + } + + this.stateRestored = true; + } + + /** + * Persist current transport state to storage. + */ + private async saveState() { + if (!this.storage) { + return; + } + + const state: TransportState = { + sessionId: this.sessionId, + initialized: this.initialized, + protocolVersion: this.protocolVersion + }; + + await Promise.resolve(this.storage.set(state)); } async start(): Promise { @@ -184,6 +243,8 @@ export class WorkerTransport implements Transport { request: Request, parsedBody?: unknown ): Promise { + await this.restoreState(); + switch (request.method) { case "OPTIONS": return this.handleOptionsRequest(request); @@ -459,6 +520,7 @@ export class WorkerTransport implements Transport { this.sessionId = this.sessionIdGenerator?.(); this.initialized = true; + await this.saveState(); if (this.sessionId && this.onsessioninitialized) { this.onsessioninitialized(this.sessionId); diff --git a/packages/agents/src/tests/mcp/handler.test.ts b/packages/agents/src/tests/mcp/handler.test.ts index 8030745f..2bae8098 100644 --- a/packages/agents/src/tests/mcp/handler.test.ts +++ b/packages/agents/src/tests/mcp/handler.test.ts @@ -133,4 +133,313 @@ describe("createMcpHandler", () => { expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); + + describe("Custom Transport Option", () => { + it("should use provided transport instead of creating new one", async () => { + const server = createTestServer(); + const { WorkerTransport } = await import("../../mcp/worker-transport"); + const customTransport = new WorkerTransport({ + corsOptions: { origin: "https://custom-transport.com" } + }); + + const handler = createMcpHandler(server, { + route: "/mcp", + transport: customTransport + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "OPTIONS" + }); + + const response = await handler(request, env, ctx); + + // Should use custom transport's CORS settings + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://custom-transport.com" + ); + }); + + it("should not connect server twice when transport already started", async () => { + const server = createTestServer(); + const { WorkerTransport } = await import("../../mcp/worker-transport"); + const customTransport = new WorkerTransport(); + + // Pre-connect the transport + await server.connect(customTransport); + expect(customTransport.started).toBe(true); + + const handler = createMcpHandler(server, { + route: "/mcp", + transport: customTransport + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "OPTIONS" + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(200); + // Transport should still be started (not restarted) + expect(customTransport.started).toBe(true); + }); + }); + + describe("WorkerTransportOptions Pass-Through", () => { + it("should pass sessionIdGenerator to transport", async () => { + const server = createTestServer(); + let customSessionIdCalled = false; + const customSessionIdGenerator = () => { + customSessionIdCalled = true; + return "custom-session-id"; + }; + + const handler = createMcpHandler(server, { + route: "/mcp", + sessionIdGenerator: customSessionIdGenerator + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(200); + expect(customSessionIdCalled).toBe(true); + expect(response.headers.get("mcp-session-id")).toBe("custom-session-id"); + }); + + it("should pass onsessioninitialized callback to transport", async () => { + const server = createTestServer(); + let capturedSessionId: string | undefined; + + const handler = createMcpHandler(server, { + route: "/mcp", + sessionIdGenerator: () => "callback-test-session", + onsessioninitialized: (sessionId: string) => { + capturedSessionId = sessionId; + } + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(200); + expect(capturedSessionId).toBeDefined(); + expect(typeof capturedSessionId).toBe("string"); + }); + + it("should pass enableJsonResponse to transport", async () => { + const server = createTestServer(); + const handler = createMcpHandler(server, { + route: "/mcp", + enableJsonResponse: true + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/json"); + }); + + it("should pass storage option to transport", async () => { + const server = createTestServer(); + const mockStorage = { + get: async () => undefined, + set: async () => {} + }; + + const handler = createMcpHandler(server, { + route: "/mcp", + storage: mockStorage + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(200); + }); + + it("should not pass handler-specific options to transport", async () => { + const server = createTestServer(); + const handler = createMcpHandler(server, { + route: "/custom-route", + authContext: { props: { userId: "123" } }, + corsOptions: { origin: "https://example.com" } + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/custom-route", { + method: "OPTIONS" + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://example.com" + ); + }); + }); + + describe("Error Handling", () => { + it("should return 500 error when transport throws", async () => { + const server = createTestServer(); + const { WorkerTransport } = await import("../../mcp/worker-transport"); + + // Create a custom transport that throws + const errorTransport = new WorkerTransport(); + errorTransport.handleRequest = async () => { + throw new Error("Transport error"); + }; + + const handler = createMcpHandler(server, { + route: "/mcp", + transport: errorTransport + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(500); + expect(response.headers.get("Content-Type")).toBe("application/json"); + + const body = (await response.json()) as any; + expect(body.jsonrpc).toBe("2.0"); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32603); + expect(body.error.message).toBe("Transport error"); + }); + + it("should return generic error message for non-Error exceptions", async () => { + const server = createTestServer(); + const { WorkerTransport } = await import("../../mcp/worker-transport"); + + const errorTransport = new WorkerTransport(); + errorTransport.handleRequest = async () => { + throw "String error"; + }; + + const handler = createMcpHandler(server, { + route: "/mcp", + transport: errorTransport + }); + + const ctx = createExecutionContext(); + const request = new Request("http://example.com/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await handler(request, env, ctx); + + expect(response.status).toBe(500); + const body = (await response.json()) as any; + expect(body.error.message).toBe("Internal server error"); + }); + }); }); diff --git a/packages/agents/src/tests/mcp/worker-transport.test.ts b/packages/agents/src/tests/mcp/worker-transport.test.ts index 0cf8107f..1b83b907 100644 --- a/packages/agents/src/tests/mcp/worker-transport.test.ts +++ b/packages/agents/src/tests/mcp/worker-transport.test.ts @@ -651,4 +651,385 @@ describe("WorkerTransport", () => { expect(body.error.message).toContain("MCP-Protocol-Version"); }); }); + + describe("Storage API - State Persistence", () => { + it("should persist session state to storage", async () => { + const server = createTestServer(); + let storedState: any = undefined; + + const mockStorage = { + get: async () => storedState, + set: async (state: any) => { + storedState = state; + } + }; + + const transport = await setupTransport(server, { + sessionIdGenerator: () => "persistent-session", + storage: mockStorage + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-06-18" + } + }) + }); + + await transport.handleRequest(request); + + expect(storedState).toBeDefined(); + expect(storedState.sessionId).toBe("persistent-session"); + expect(storedState.initialized).toBe(true); + expect(storedState.protocolVersion).toBe("2025-06-18"); + }); + + it("should restore session state from storage", async () => { + const server = createTestServer(); + const existingState = { + sessionId: "restored-session", + initialized: true, + protocolVersion: "2025-06-18" as const + }; + + const mockStorage = { + get: async () => existingState, + set: async () => {} + }; + + const transport = await setupTransport(server, { + storage: mockStorage + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "restored-session", + "MCP-Protocol-Version": "2025-06-18" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized" + }) + }); + + const response = await transport.handleRequest(request); + + expect(transport.sessionId).toBe("restored-session"); + expect(response.status).toBe(202); + }); + + it("should handle storage with no existing state", async () => { + const server = createTestServer(); + const mockStorage = { + get: async () => undefined, + set: async () => {} + }; + + const transport = await setupTransport(server, { + sessionIdGenerator: () => "new-session", + storage: mockStorage + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).toBe("new-session"); + }); + + it("should only restore state once", async () => { + const server = createTestServer(); + let getCalls = 0; + + const mockStorage = { + get: async () => { + getCalls++; + return { + sessionId: "restored-session", + initialized: true, + protocolVersion: "2025-03-26" as const + }; + }, + set: async () => {} + }; + + const transport = await setupTransport(server, { + storage: mockStorage + }); + + // Make multiple requests + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "restored-session" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized" + }) + }); + + await transport.handleRequest(request); + await transport.handleRequest(request); + + expect(getCalls).toBe(1); + }); + }); + + describe("Session Management", () => { + it("should use custom sessionIdGenerator", async () => { + const server = createTestServer(); + let generatorCalled = false; + + const transport = await setupTransport(server, { + sessionIdGenerator: () => { + generatorCalled = true; + return "custom-generated-id"; + } + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(generatorCalled).toBe(true); + expect(response.headers.get("mcp-session-id")).toBe( + "custom-generated-id" + ); + expect(transport.sessionId).toBe("custom-generated-id"); + }); + + it("should fire onsessioninitialized callback", async () => { + const server = createTestServer(); + let callbackSessionId: string | undefined; + let callbackCalled = false; + + const transport = await setupTransport(server, { + sessionIdGenerator: () => "callback-test-session", + onsessioninitialized: (sessionId: string) => { + callbackCalled = true; + callbackSessionId = sessionId; + } + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + await transport.handleRequest(request); + + expect(callbackCalled).toBe(true); + expect(callbackSessionId).toBe("callback-test-session"); + }); + + it("should only call onsessioninitialized once per session", async () => { + const server = createTestServer(); + let callbackCount = 0; + + const transport = await setupTransport(server, { + sessionIdGenerator: () => "single-callback-session", + onsessioninitialized: () => { + callbackCount++; + } + }); + + // First request - initialize + const initRequest = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + await transport.handleRequest(initRequest); + + const followupRequest = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "single-callback-session" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized" + }) + }); + + await transport.handleRequest(followupRequest); + + expect(callbackCount).toBe(1); + }); + }); + + describe("JSON Response Mode", () => { + it("should return JSON response when enableJsonResponse is true", async () => { + const server = createTestServer(); + const transport = await setupTransport(server, { + enableJsonResponse: true + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/json"); + + const body = await response.json(); + expect(body).toBeDefined(); + expect((body as any).jsonrpc).toBe("2.0"); + }); + + it("should return SSE stream when enableJsonResponse is false", async () => { + const server = createTestServer(); + const transport = await setupTransport(server, { + enableJsonResponse: false + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + }); + + it("should return JSON when enableJsonResponse is true regardless of Accept header order", async () => { + const server = createTestServer(); + const transport = await setupTransport(server, { + enableJsonResponse: true + }); + + const request = new Request("http://example.com/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream, application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "1", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0" }, + protocolVersion: "2025-03-26" + } + }) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/json"); + }); + }); });