diff --git a/public/__redirects b/public/__redirects index ea7b53a8412..b45937c8288 100644 --- a/public/__redirects +++ b/public/__redirects @@ -319,6 +319,8 @@ /agents/guides/webhooks/ /agents/communication-channels/webhooks/ 301 /agents/model-context-protocol/authorization/ /agents/model-context-protocol/protocol/authorization/ 301 /agents/model-context-protocol/protocol/codemode/ /agents/tools/codemode/ 301 +/agents/model-context-protocol/codemode/ /agents/model-context-protocol/guides/build-codemode-mcp-server/ 301 +/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/ /agents/model-context-protocol/guides/build-codemode-mcp-server/ 301 /agents/model-context-protocol/governance/ /agents/model-context-protocol/protocol/governance/ 301 /agents/model-context-protocol/mcp-portal/ /agents/model-context-protocol/cloudflare/mcp-portal/ 301 /agents/model-context-protocol/mcp-servers-for-cloudflare/ /agents/model-context-protocol/cloudflare/servers-for-cloudflare/ 301 diff --git a/src/content/changelog/access/2026-03-26-mcp-portal-code-mode.mdx b/src/content/changelog/access/2026-03-26-mcp-portal-code-mode.mdx index 75cfa228a0f..a606b3105e8 100644 --- a/src/content/changelog/access/2026-03-26-mcp-portal-code-mode.mdx +++ b/src/content/changelog/access/2026-03-26-mcp-portal-code-mode.mdx @@ -6,7 +6,7 @@ products: - access --- -[MCP server portals](/cloudflare-one/access-controls/ai-controls/mcp-portals/) support [Code Mode MCP server patterns](/agents/model-context-protocol/codemode/), a technique that reduces context window usage by replacing individual tool definitions with a single code execution tool. Code Mode is turned on by default on all portals. +[MCP server portals](/cloudflare-one/access-controls/ai-controls/mcp-portals/) support [Build a Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/), a technique that reduces context window usage by replacing individual tool definitions with a single code execution tool. Code Mode is turned on by default on all portals. To turn it off, edit the portal in **Access controls** > **AI controls** and turn off **Code Mode** under **Basic information**. diff --git a/src/content/changelog/agents/2026-10-01-workers-oauth-provider-1x.mdx b/src/content/changelog/agents/2026-10-01-workers-oauth-provider-1x.mdx index 64ecddf82a5..87fc7c1b0ee 100644 --- a/src/content/changelog/agents/2026-10-01-workers-oauth-provider-1x.mdx +++ b/src/content/changelog/agents/2026-10-01-workers-oauth-provider-1x.mdx @@ -19,7 +19,7 @@ import { - `insufficientScope()` gives you [step-up authorization](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/authorization-server.md#scopes-and-step-up-authorization) in one line. - The authorization server and the resource server can run in [different Workers](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/resource-servers.md#separate-workers) with different [WAF](/waf/) and [rate limiting](/waf/rate-limiting-rules/) rules. - One authorization server can issue tokens for [many MCP servers](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/authorization-server.md#resources-and-token-audiences). -- A [migration skill](https://github.com/cloudflare/workers-oauth-provider/blob/main/skills/migrate-to-1.0/SKILL.md) ships in the npm package so a coding agent can perform the upgrade. +- A [migration skill](https://github.com/cloudflare/workers-oauth-provider/blob/main/skills/migrate-to-1.x/SKILL.md) ships in the npm package so a coding agent can perform the upgrade. ## The split API @@ -117,6 +117,6 @@ You can still use `OAuthProvider` as both the authorization server and the MCP s -Point your coding agent at `node_modules/@cloudflare/workers-oauth-provider/skills/migrate-to-1.0/SKILL.md`, or follow the [migration guide](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/migration-1.0.md). +Point your coding agent at `node_modules/@cloudflare/workers-oauth-provider/skills/migrate-to-1.x/SKILL.md`, or follow the [migration guide](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/migration-1.x.md). For both Workers in full, refer to the [split Workers example](https://github.com/cloudflare/workers-oauth-provider/tree/main/examples/split-workers). diff --git a/src/content/docs/agent-lee/index.mdx b/src/content/docs/agent-lee/index.mdx index eeff4575b93..631def6a886 100644 --- a/src/content/docs/agent-lee/index.mdx +++ b/src/content/docs/agent-lee/index.mdx @@ -166,13 +166,13 @@ Agent Lee is entirely optional. If you do not open the Ask AI panel, none of you Agent Lee is built on Cloudflare's own developer platform using the same primitives available to any Cloudflare developer. -| Component | Role | -| ------------------------------------------------------------- | ----------------------------------------------------- | -| [Agents SDK](/agents/) | Agent lifecycle, state management, and scheduling | -| [Durable Objects](/durable-objects/) | Per-user conversation storage and write approval gate | -| [Workers AI](/workers-ai/) | LLM inference | -| [AI Gateway](/ai-gateway/) | Model request routing, logging, and analytics | -| [Cloudflare MCP server](/agents/model-context-protocol/apis/agent-api/) | Tool definitions for Cloudflare API operations | +| Component | Role | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------- | +| [Agents SDK](/agents/) | Agent lifecycle, state management, and scheduling | +| [Durable Objects](/durable-objects/) | Per-user conversation storage and write approval gate | +| [Workers AI](/workers-ai/) | LLM inference | +| [AI Gateway](/ai-gateway/) | Model request routing, logging, and analytics | +| [Cloudflare MCP server](/agents/model-context-protocol/cloudflare/servers-for-cloudflare/) | Tool definitions for Cloudflare API operations | --- diff --git a/src/content/docs/agents/concepts/agentic-patterns/human-in-the-loop.mdx b/src/content/docs/agents/concepts/agentic-patterns/human-in-the-loop.mdx index 6782f3a25bb..bddc68dd5f4 100644 --- a/src/content/docs/agents/concepts/agentic-patterns/human-in-the-loop.mdx +++ b/src/content/docs/agents/concepts/agentic-patterns/human-in-the-loop.mdx @@ -334,7 +334,7 @@ Configure the user-facing handlers in `onStart()`: ```ts import { Agent } from "agents"; -import type { ElicitRequest, ElicitResult } from "agents/mcp"; +import type { ElicitRequest, ElicitResult } from "agents/mcp/client"; export class MyAgent extends Agent { onStart() { @@ -360,7 +360,7 @@ export class MyAgent extends Agent { -For the complete form, URL, and browser forwarding patterns, refer to [MCP client elicitation](/agents/model-context-protocol/apis/client-api/#elicitation). For the server-side request API, refer to [McpAgent elicitation](/agents/model-context-protocol/apis/agent-api/#elicitation). +For the complete form, URL, and browser forwarding patterns, refer to [MCP client elicitation](/agents/model-context-protocol/apis/client-api/#elicitation). For the server side, refer to [Elicitation with a stateless handler](/agents/model-context-protocol/apis/handler-api/#elicitation-with-a-stateless-handler). ## Durable Code Mode approvals @@ -588,7 +588,7 @@ class MultiApprovalAgent extends Agent { diff --git a/src/content/docs/agents/concepts/tools.mdx b/src/content/docs/agents/concepts/tools.mdx index 79911fecab9..560a09fbd7f 100644 --- a/src/content/docs/agents/concepts/tools.mdx +++ b/src/content/docs/agents/concepts/tools.mdx @@ -68,7 +68,7 @@ An Agent can expose MCP tools through either model interface: - Pass MCP tools directly to a model with the [Agents MCP client](/agents/tools/mcp/). - Expose MCP tools inside Code Mode for composition and progressive discovery with [MCP connectors](/agents/tools/codemode/mcp/). -An MCP server can also expose Code Mode itself. For example, it can present one `code` tool or separate `search` and `execute` tools. For these server-side patterns, refer to [Code Mode MCP server patterns](/agents/model-context-protocol/codemode/). +An MCP server can also expose Code Mode itself. For example, it can present one `code` tool or separate `search` and `execute` tools. For these server-side patterns, refer to [Build a Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/). ## Control side effects diff --git a/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx b/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx index 9f140b05109..dfacfff50c1 100644 --- a/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx +++ b/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx @@ -1,11 +1,11 @@ --- pcx_content_type: reference -title: McpAgent +title: McpAgent (deprecated) description: Reference the deprecated, feature-frozen McpAgent class while migrating existing stateful MCP servers to stateless handlers. tags: - MCP sidebar: - order: 25 + order: 27 products: - agents --- @@ -121,6 +121,10 @@ export default new OAuthProvider({ tokenEndpoint: "/token", clientRegistrationEndpoint: "/register", defaultHandler: AuthHandler, + resourceMetadata: { + // The URL MCP clients connect to. Use http://localhost:8787/mcp with wrangler dev. + resource: "https://my-mcp-server..workers.dev/mcp", + }, }); ``` @@ -187,19 +191,258 @@ const eventStore = new DurableObjectEventStore(this.ctx.storage); -Refer to [MCP Transport](/agents/model-context-protocol/protocol/transport/) for transport configuration. +## RPC transport -## Authentication and authorization +The **RPC transport** is designed for internal applications where your MCP server and agent are both running on Cloudflare — they can even run in the same Worker. It sends JSON-RPC messages directly over Cloudflare's [RPC bindings](/workers/runtime-apis/bindings/service-bindings/rpc/) without going over the public internet. + +- **Faster** — no network overhead, direct function calls between Durable Objects +- **Simpler** — no HTTP endpoints, no connection management +- **Internal only** — perfect for agents calling MCP servers within the same Worker + +RPC transport does not support authentication. Use Streamable HTTP for external connections that require OAuth. + +### Connect an Agent to an existing `McpAgent` through RPC + +#### 1. Define your MCP server + +Create your `McpAgent` with the tools you want to expose: + + + +```ts +import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +type State = { counter: number }; + +export class MyMCP extends McpAgent { + server = new McpServer({ name: "MyMCP", version: "1.0.0" }); + initialState: State = { counter: 0 }; + + async init() { + this.server.tool( + "add", + "Add to the counter", + { amount: z.number() }, + async ({ amount }) => { + this.setState({ counter: this.state.counter + amount }); + return { + content: [ + { + type: "text", + text: `Added ${amount}, total is now ${this.state.counter}`, + }, + ], + }; + }, + ); + } +} +``` + + + +#### 2. Connect your Agent to the MCP server + +In your `Agent`, call `addMcpServer()` with the Durable Object binding in `onStart()`: + + + +```ts +import { AIChatAgent } from "@cloudflare/ai-chat"; + +export class Chat extends AIChatAgent { + async onStart(): Promise { + // Pass the DO namespace binding directly + await this.addMcpServer("my-mcp", this.env.MyMCP); + } + + async onChatMessage(onFinish) { + const allTools = this.mcp.getAITools(); + + const result = streamText({ + model, + tools: allTools, + // ... + }); + + return createUIMessageStreamResponse({ stream: result }); + } +} +``` + + + +RPC connections are automatically restored after Durable Object hibernation, just like HTTP connections. The binding name and props are persisted to storage so the connection can be re-established without any extra code. + +For RPC transport, if `addMcpServer` is called with a name that already has an active connection, the existing connection is returned instead of creating a duplicate. For HTTP transport, deduplication matches on both server name and URL (refer to [MCP Client API](/agents/model-context-protocol/apis/client-api/) for details). This makes it safe to call in `onStart()`. + +#### 3. Configure Durable Object bindings + +In your `wrangler.jsonc`, define bindings for both Durable Objects: + +```jsonc +{ + "durable_objects": { + "bindings": [ + { "name": "Chat", "class_name": "Chat" }, + { "name": "MyMCP", "class_name": "MyMCP" }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": ["MyMCP", "Chat"], + "tag": "v1", + }, + ], +} +``` + +#### 4. Set up your Worker fetch handler + +Route requests to your Chat agent: + + + +```ts +import { routeAgentRequest } from "agents"; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + const url = new URL(request.url); + + // Optionally expose the MCP server via HTTP as well + if (url.pathname.startsWith("/mcp")) { + return MyMCP.serve("/mcp").fetch(request, env, ctx); + } + + const response = await routeAgentRequest(request, env); + if (response) return response; + + return new Response("Not found", { status: 404 }); + }, +} satisfies ExportedHandler; +``` + + -The McpAgent class provides seamless integration with the [OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider) for [authentication and authorization](/agents/model-context-protocol/protocol/authorization/). +### Passing props to the MCP server -When a user authenticates to your MCP server, their identity information and tokens are made available through the `props` parameter, allowing you to: +Since RPC transport does not have an OAuth flow, you can pass user context directly as props: + + + +```ts +await this.addMcpServer("my-mcp", this.env.MyMCP, { + props: { userId: "user-123", role: "admin" }, +}); +``` + + + +Your `McpAgent` can then access these props: + + + +```ts +export class MyMCP extends McpAgent< + Env, + State, + { userId?: string; role?: string } +> { + async init() { + this.server.tool("whoami", "Get current user info", {}, async () => { + const userId = this.props?.userId || "anonymous"; + const role = this.props?.role || "guest"; + + return { + content: [{ type: "text", text: `User ID: ${userId}, Role: ${role}` }], + }; + }); + } +} +``` + + + +Props are type-safe (TypeScript extracts the Props type from your `McpAgent` generic), persistent (stored in Durable Object storage), and available immediately before any tool calls are made. + +### Configuring RPC transport server timeout + +The RPC transport has a configurable timeout for waiting for tool responses. By default, the server waits **60 seconds** for a tool handler to respond. You can customize this by overriding `getRpcTransportOptions()` in your `McpAgent`: + + + +```ts +export class MyMCP extends McpAgent { + server = new McpServer({ name: "MyMCP", version: "1.0.0" }); + + protected getRpcTransportOptions() { + return { timeout: 120000 }; // 2 minutes + } + + async init() { + this.server.tool( + "long-running-task", + "A tool that takes a while", + { input: z.string() }, + async ({ input }) => { + await longRunningOperation(input); + return { + content: [{ type: "text", text: "Task completed" }], + }; + }, + ); + } +} +``` + + + +## Authentication and authorization + +The McpAgent class integrates with the [OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider). When a user authenticates to your MCP server, their identity information and tokens are available through `this.props`, allowing you to: - access user-specific data - check user permissions before performing operations - customize responses based on user attributes - use authentication tokens to make requests to external services on behalf of the user +The third type parameter defines the shape of `props`: + + + +```ts +import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +type AuthContext = { + claims: { sub: string; name: string; email: string }; + permissions: string[]; +}; + +export class MyMCP extends McpAgent { + server = new McpServer({ name: "Auth Demo", version: "1.0.0" }); + + async init() { + this.server.tool("whoami", "Get the current user", {}, async () => ({ + content: [{ type: "text", text: `Hello, ${this.props.claims.name}!` }], + })); + + if (this.props.permissions?.includes("admin")) { + this.server.tool("adminTool", "Admin only", {}, async () => ({ + content: [{ type: "text", text: "Admin action completed" }], + })); + } + } +} +``` + + + +For a stateless server, read the same props with `getMcpAuthContext()`. Refer to [Authorization](/agents/model-context-protocol/protocol/authorization/). + ## State synchronization APIs The `McpAgent` class provides full access to the [Agent state APIs](/agents/runtime/lifecycle/state/): @@ -211,7 +454,7 @@ The `McpAgent` class provides full access to the [Agent state APIs](/agents/runt - [`sql`](/agents/runtime/agents-api/#sql-api) — Execute SQL queries on embedded database :::note[State resets after the session ends] -Currently, each client session is backed by an instance of the `McpAgent` class. This is handled automatically for you, as shown in the [getting started guide](/agents/model-context-protocol/guides/remote-mcp-server/). This means that when the same client reconnects, they will start a new session, and the state will be reset. +Each client session is backed by an instance of the `McpAgent` class. This means that when the same client reconnects, they will start a new session, and the state will be reset. ::: For example, the following code implements an MCP server that remembers a counter value, and updates the counter when the `add` tool is called: @@ -391,27 +634,9 @@ For more human-in-the-loop patterns, refer to [Human-in-the-loop patterns](/agen ## Next steps - - - - - - { The example uses a 55-second timeout because MCP SDK requests default to 60 seconds. If your client call sets a longer request timeout, adjust this timeout to finish first. -Refer to the [`mcp-client` example](https://github.com/cloudflare/agents/tree/main/examples/mcp-client) for the browser implementation. [`mcp-elicitation-mrtr`](https://github.com/cloudflare/agents/tree/main/examples/mcp-elicitation-mrtr) demonstrates stateless elicitation. [`mcp-elicitation`](https://github.com/cloudflare/agents/tree/main/examples/mcp-elicitation) demonstrates legacy elicitation. +Refer to the [`mcp-client` example](https://github.com/cloudflare/agents/tree/main/examples/mcp-client) for the browser implementation. [`mcp-elicitation-mrtr`](https://github.com/cloudflare/agents/tree/main/examples/mcp-elicitation-mrtr) demonstrates stateless elicitation. -For server-side patterns, refer to [Elicitation with a stateless handler](/agents/model-context-protocol/apis/handler-api/#elicitation-with-a-stateless-handler) and [Elicitation on legacy servers](/agents/model-context-protocol/apis/agent-api/#elicitation-on-legacy-servers). +For the server side, refer to [Elicitation with a stateless handler](/agents/model-context-protocol/apis/handler-api/#elicitation-with-a-stateless-handler). ## Managing servers @@ -699,21 +697,9 @@ async addMcpServer( | { id: string; state: "authenticating"; authUrl: string } | { id: string; state: "ready" } > - -// RPC transport (Durable Object binding — no HTTP overhead) -async addMcpServer( - serverName: string, - binding: DurableObjectNamespace, - options?: { - id?: string; - props?: Record; - client?: McpClientOptions; - retry?: RetryOptions; - } -): Promise<{ id: string; state: "ready" }> ``` -#### Parameters (HTTP transport) +#### Parameters - `serverName` (string, required) — Display name for the MCP server - `url` (string, required) — URL of the MCP server endpoint @@ -728,18 +714,6 @@ async addMcpServer( - `type` — Transport type: `"auto"` (default), `"streamable-http"`, or `"sse"` - `retry` — Retry options for connection and reconnection attempts. Persisted and used when restoring connections after hibernation or after OAuth completion. Default: 3 attempts, 500ms base delay, 5s max delay. Refer to [Retries](/agents/runtime/execution/retries/) for details on `RetryOptions`. -#### Parameters (RPC transport) - -- `serverName` (string, required) — Display name for the MCP server -- `binding` (`DurableObjectNamespace`, required) — The Durable Object binding for the `McpAgent` class -- `options` (object, optional) — Connection configuration: - - `id` — Optional stable, caller-supplied server ID. Refer to [Stable server IDs](#stable-server-ids) - - `props` — Initialization data passed to the `McpAgent`'s `onStart(props)`. Use this to pass user context, configuration, or other data to the MCP server instance - - `client` — MCP client configuration options - - `retry` — Retry options for the connection - -RPC transport connects your Agent directly to an `McpAgent` via Durable Object bindings without HTTP overhead. Refer to [MCP Transport](/agents/model-context-protocol/protocol/transport/) for details on configuring RPC transport. - #### Returns A Promise that resolves to a discriminated union based on connection state: @@ -1240,8 +1214,8 @@ export class MyAgent extends Agent { - -For an explicit legacy server: - - + -Use the exact MCP versions required by your installed Agents release. +Use the exact `@modelcontextprotocol/server` version required by your installed Agents release. ## `createMcpHandler` @@ -141,7 +130,7 @@ Elicitation through a stateless handler returns `input_required` and completes t Use `inputRequired(...)` to request input. Read that round's accepted form content from `context.mcpReq.inputResponses` with `acceptedContent(...)`. Seal trusted intermediate values needed by later rounds into integrity-protected `requestState`. -Refer to the [stateless elicitation example](https://github.com/cloudflare/agents/tree/main/examples/mcp-elicitation-mrtr) for a two-round tool flow. For stateful pushed requests, refer to [Elicitation on legacy servers](/agents/model-context-protocol/apis/agent-api/#elicitation-on-legacy-servers). +Refer to the [stateless elicitation example](https://github.com/cloudflare/agents/tree/main/examples/mcp-elicitation-mrtr) for a two-round tool flow. ### Origin validation and CORS @@ -154,16 +143,25 @@ For a custom domain with wildcard CORS, set `allowedHostnames` and `allowedOrigi ```ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; + +function createServer() { + return new McpServer({ name: "my-server", version: "1.0.0" }); +} + +const handler = createMcpHandler(createServer, { + allowedHostnames: ["mcp.example.com"], + corsOptions: { + origin: "https://app.example.com", + }, +}); + export default { fetch(request, env, ctx) { - return createMcpHandler(createServer, { - allowedHostnames: ["mcp.example.com"], - corsOptions: { - origin: "https://app.example.com", - }, - })(request, env, ctx); + return handler(request, env, ctx); }, -} satisfies ExportedHandler; +} satisfies ExportedHandler; ``` @@ -178,7 +176,7 @@ The handler does not infer a Host allowlist from `request.url`. If a deployment ### Compatibility with legacy clients -The default `legacy: "stateless"` setting accepts ordinary legacy tools, prompts, and resources. This lane uses the SDK v2 web-standard transport and does not import `WorkerTransport`. +The default `legacy: "stateless"` setting accepts ordinary legacy tools, prompts, and resources. This lane uses the SDK v2 web-standard transport. This compatibility path does not provide a complete session transport: @@ -189,7 +187,7 @@ This compatibility path does not provide a complete session transport: - Standalone streams, resumability, replay, and session deletion are unavailable. - Published experimental tasks are not supported through this path. -Set `legacy: "reject"` for a stateless-only endpoint. During migration, route legacy clients that still require protocol sessions to a temporary `createLegacyMcpHandler` or `McpAgent` lane. +Set `legacy: "reject"` for a stateless-only endpoint. ### Return value @@ -222,25 +220,67 @@ type McpHandlerRequestOptions = { Call the handler from a Worker's object `fetch()` export: + + ```ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; + +function createServer() { + return new McpServer({ name: "my-server", version: "1.0.0" }); +} + +const handler = createMcpHandler(createServer); + export default { fetch(request, env, ctx) { - return createMcpHandler(createServer)(request, env, ctx); + return handler(request, env, ctx); }, -} satisfies ExportedHandler; +} satisfies ExportedHandler; ``` + + Do not export the callable directly as a Worker's default export. Wrangler treats function default exports as `WorkerEntrypoint` classes. Use `fetch()` when another framework or authentication layer has already parsed or validated request data: + + ```ts -const response = await handler.fetch(request, { - authInfo, - parsedBody, -}); +import { McpServer, type AuthInfo } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; + +function createServer() { + return new McpServer({ name: "my-server", version: "1.0.0" }); +} + +const handler = createMcpHandler(createServer); + +// Replace with your own token verification. +async function verifyToken(token: string): Promise { + if (token !== "dev-token") return undefined; + return { token, clientId: "dev-client", scopes: ["mcp:read"] }; +} + +export default { + async fetch(request) { + const token = request.headers.get("Authorization")?.replace(/^Bearer /, ""); + const authInfo = token ? await verifyToken(token) : undefined; + if (!authInfo) return new Response("Unauthorized", { status: 401 }); + + // Middleware that already read the body can pass it on, so the handler + // does not read it again. + const parsedBody = + request.method === "POST" ? await request.clone().json() : undefined; + + return handler.fetch(request, { authInfo, parsedBody }); + }, +} satisfies ExportedHandler; ``` + + `authInfo` is passed to the server factory and request handlers. The handler does not derive it from request headers or verify access tokens. `parsedBody` avoids reparsing a JSON body that upstream middleware already consumed. #### Publish list and resource changes @@ -260,92 +300,103 @@ Calling a notifier when no matching subscription is open is a no-op. Notification routing belongs to the handler instance. Constructing a new handler inside every Worker `fetch()` call is suitable for ordinary tools, prompts, resources, and MRTR elicitation. It cannot notify a `subscriptions/listen` stream owned by an earlier handler instance. -Create the handler once at module scope when using `notify` or `subscriptions/listen`, then invoke it from the Worker object export: +Create the handler once at module scope when using `notify` or `subscriptions/listen`, then invoke it from the Worker object export. This Worker also has an admin route, protected by an `ADMIN_TOKEN` secret, that tells subscribed clients to fetch the tool list again: + + ```ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; + +function createServer() { + return new McpServer({ name: "my-server", version: "1.0.0" }); +} + +// Create the handler once, so notifications reach its open streams. const handler = createMcpHandler(createServer); export default { - fetch(request, env, ctx) { + async fetch(request, env, ctx) { + const url = new URL(request.url); + + if (url.pathname === "/admin/tools-changed" && request.method === "POST") { + if (request.headers.get("Authorization") !== `Bearer ${env.ADMIN_TOKEN}`) { + return new Response("Unauthorized", { status: 401 }); + } + handler.notify.toolsChanged(); + return new Response(null, { status: 204 }); + } + return handler(request, env, ctx); }, -} satisfies ExportedHandler; +} satisfies ExportedHandler; ``` + + Notifications are isolate-local. A notification published in one Worker isolate does not reach a subscription stream running in another isolate. -## `createLegacyMcpHandler` +## Authentication context -`createLegacyMcpHandler` serves an SDK v1 server through `WorkerTransport`. +To require OAuth, wrap the handler in an `OAuthResourceServer` from `@cloudflare/workers-oauth-provider`. It validates the access token with your authorization server before it calls the handler. Refer to [Authorization](/agents/model-context-protocol/protocol/authorization/) to build the authorization server. -```ts -import { - createLegacyMcpHandler, - type CreateLegacyMcpHandlerOptions, - type LegacyMcpHandler, -} from "agents/mcp"; -import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - -function createLegacyMcpHandler( - server: McpServer | Server, - options?: CreateLegacyMcpHandlerOptions, -): LegacyMcpHandler; -``` + -Use this handler only as a temporary migration bridge when an existing SDK v1 endpoint still requires legacy sessions, transport storage, event replay, or pushed server-to-client requests. +```ts title="src/index.ts" +import { + OAuthResourceServer, + insufficientScope, + type AuthorizationServerBinding, +} from "@cloudflare/workers-oauth-provider"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; - +type AuthProps = { userId: string }; -```ts -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { createLegacyMcpHandler } from "agents/mcp"; +interface Env { + AUTH_SERVER: AuthorizationServerBinding; +} function createServer() { - return new McpServer({ name: "legacy-server", version: "1.0.0" }); + const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }); + const props = getMcpAuthContext()?.props as AuthProps; + + server.registerTool( + "whoami", + { description: "Return the signed-in user" }, + async () => ({ + content: [{ type: "text", text: props.userId }], + }), + ); + + return server; } -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { - return createLegacyMcpHandler(createServer())(request, env, ctx); +const mcpHandler = createMcpHandler(createServer); + +export default new OAuthResourceServer({ + resourceMetadata: { + resource: env.MCP_SERVER_URL, // tokens for any other resource are rejected + authorization_servers: [env.AUTH_SERVER_URL], }, -} satisfies ExportedHandler; + requiredScopes: ["mcp:read"], + validateToken: (env) => env.AUTH_SERVER.validateToken, + handler: { + fetch(request, env, ctx) { + // requiredScopes is advertised, not enforced: check it here. + if (!ctx.auth.scope.includes("mcp:read")) { + return insufficientScope(ctx.auth, ["mcp:read"]); + } + return mcpHandler(request, env, ctx); + }, + }, +}); ``` -Passing an SDK v1 server to `createMcpHandler` still works but emits a deprecation warning. Move the server to an SDK v2 factory and pass the factory to `createMcpHandler`. If sessionful behavior prevents an immediate migration, use `createLegacyMcpHandler` only on the temporary legacy lane. - -`experimental_createMcpHandler` is also deprecated. Move its SDK v1 server to an SDK v2 factory. Use `createLegacyMcpHandler` only as a temporary bridge for sessionful behavior. - -### `CreateLegacyMcpHandlerOptions` - -`CreateLegacyMcpHandlerOptions` extends `WorkerTransportOptions` and adds these fields: - -| Option | Type | Default | Description | -| ------------- | ----------------- | ----------------------- | ------------------------------------- | -| `route` | `string` | `"/mcp"` | Exact path handled by the handler | -| `authContext` | `McpAuthContext` | Execution context props | Application props for tool handlers | -| `transport` | `WorkerTransport` | New transport | Persistent or preconfigured transport | - -Common `WorkerTransportOptions` include: - -| Option | Description | -| ----------------------------------------- | ---------------------------------------------------------- | -| `sessionIdGenerator` | Creates protocol session IDs | -| `enableJsonResponse` | Returns JSON instead of SSE where supported | -| `storage` | Persists transport state through an `{ get, set }` adapter | -| `eventStore` | Persists events for replay and stream recovery | -| `corsOptions` | Adds CORS response and preflight headers | -| `onsessioninitialized`, `onsessionclosed` | Observe session lifecycle changes | - -Create a fresh SDK v1 server for each request unless you provide a persistent transport already connected to that server. One server cannot reconnect to several transports. - -## Authentication context - -A compatible `@cloudflare/workers-oauth-provider` supplies verified standard `AuthInfo` to SDK v2 callbacks at `context.http.authInfo`. - -The existing `getMcpAuthContext()` helper continues to return application props: +The resource server passes the `props` stored with `completeAuthorization()` as `ctx.props`, and `getMcpAuthContext()` returns them inside your server: ```ts interface McpAuthContext { @@ -353,41 +404,68 @@ interface McpAuthContext { } ``` +`getMcpAuthContext()` also works inside the server factory, so you can register tools based on who is signed in. When your own code authenticates the request instead of `OAuthResourceServer`, pass the identity with the `authContext` option: + ```ts -import { getMcpAuthContext } from "agents/mcp/server"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; import { z } from "zod"; -server.registerTool( - "whoami", - { description: "Return the current identity", inputSchema: z.object({}) }, - async (_args, context) => { - const auth = getMcpAuthContext(); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - clientId: context.http?.authInfo?.clientId, - scopes: context.http?.authInfo?.scopes, - userId: auth?.props.userId, - }), - }, - ], - }; +type Props = { userId: string; role: "admin" | "member" }; + +function createServer() { + const server = new McpServer({ name: "my-server", version: "1.0.0" }); + const props = getMcpAuthContext()?.props as Props | undefined; + + server.registerTool( + "whoami", + { description: "Return the signed-in user" }, + async () => ({ + content: [{ type: "text", text: props?.userId ?? "anonymous" }], + }), + ); + + // Only admins see this tool. + if (props?.role === "admin") { + server.registerTool( + "deleteUser", + { description: "Delete a user", inputSchema: { userId: z.string() } }, + async ({ userId }) => ({ + content: [{ type: "text", text: `Deleted ${userId}` }], + }), + ); + } + + return server; +} + +// Replace with your own session or token check. +async function authenticate(request: Request): Promise { + if (request.headers.get("Authorization") === "Bearer dev-token") { + return { userId: "user-123", role: "admin" }; + } + return undefined; +} + +export default { + async fetch(request, env, ctx) { + const props = await authenticate(request); + if (!props) return new Response("Unauthorized", { status: 401 }); + + return createMcpHandler(createServer, { authContext: { props } })( + request, + env, + ctx, + ); }, -); +} satisfies ExportedHandler; ``` -Do not log or return `authInfo.token` or `authInfo.extra.props`. - -## Migration - -Refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/) before changing an existing server. The migration guide covers dual-era routing, stateful servers, client changes, and rollout checks. +Props often hold upstream access tokens. Do not log or return them. ## Related resources @@ -397,12 +475,6 @@ Refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-t description="Choose a migration path and roll out the SDK upgrade." /> - - { - const projects = await codemode.list_projects({ status: "active" }); - const tasks = []; - - for (const project of projects) { - tasks.push(...(await codemode.list_tasks({ projectId: project.id }))); - } - - return tasks.filter((task) => task.status === "blocked"); -}; -``` - -The MCP client makes one outer tool call. Inside the sandbox, the code can make dependent upstream calls, filter intermediate data, and return only the final result. - -This pattern works well when the generated type declarations fit comfortably in the `code` tool description. The model receives those declarations when it loads the MCP tool. - -To implement this pattern, refer to [Build a single-tool Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/). - -## Search and execute - -A large API can have thousands of operations. Including every operation in one tool description would still consume substantial context. The search-and-execute pattern separates capability discovery from authenticated API calls. - -The server exposes two MCP tools: - -- `search` runs generated code against an OpenAPI document. It returns only the operations, parameters, or schemas needed for the task. -- `execute` runs generated code with an authenticated request function. It can call the selected operations, compose responses, and return a focused result. - -The model first calls `search` with code such as: - -```js -async () => { - const spec = await codemode.spec(); - return Object.entries(spec.paths) - .filter(([path]) => path.includes("/rulesets")) - .map(([path, operations]) => ({ - path, - methods: Object.keys(operations), - })); -}; -``` - -The complete OpenAPI document remains inside the sandbox. Only the returned subset enters the model context. - -After selecting an operation, the model calls `execute`: - -```js -async () => { - const response = await codemode.request({ - method: "GET", - path: `/zones/${zoneId}/rulesets`, - }); - - return response.result.map(({ id, name, phase }) => ({ id, name, phase })); -}; -``` - -Authentication stays in the host request callback. The generated code receives a request function, not the credential. - -The [Cloudflare API MCP server](/agents/model-context-protocol/cloudflare/servers-for-cloudflare/) uses this pattern to expose the Cloudflare API through `search` and `execute`. For the design rationale and context savings, refer to [Code Mode: give agents an entire API in 1,000 tokens](https://blog.cloudflare.com/code-mode-mcp/). - -To implement this pattern, refer to [Build a search and execute MCP server](/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/). - -## Sandbox and authorization boundary - -Model-written code runs in an isolated Worker. Direct outbound network access is blocked by default. Generated code reaches external systems only through upstream MCP tools or a host-provided request callback. - -Code execution does not replace authorization. Enforce permissions and any required approval inside upstream tool handlers or the host request callback before applying side effects. Do not expose credentials through tool results or OpenAPI documents. - -## Choose a pattern - -Use `codeMcpServer()` when an existing MCP server already defines the operations and schemas the model needs. Use `openApiMcpServer()` when a large OpenAPI catalog needs progressive discovery and a fixed model-context footprint. - - - - - diff --git a/src/content/docs/agents/model-context-protocol/guides/build-codemode-mcp-server.mdx b/src/content/docs/agents/model-context-protocol/guides/build-codemode-mcp-server.mdx index 9189ebc0270..d6e8a260110 100644 --- a/src/content/docs/agents/model-context-protocol/guides/build-codemode-mcp-server.mdx +++ b/src/content/docs/agents/model-context-protocol/guides/build-codemode-mcp-server.mdx @@ -1,6 +1,6 @@ --- -title: Build a single-tool Code Mode MCP server -description: Replace an MCP server's individual tools with one sandboxed Code Mode tool on Cloudflare Workers. +title: Build a Code Mode MCP server +description: Expose an MCP server or a large OpenAPI service to any MCP client through sandboxed, model-written code. pcx_content_type: how-to sidebar: order: 14 @@ -18,21 +18,47 @@ import { WranglerConfig, } from "~/components"; -Use `codeMcpServer()` to wrap an existing Model Context Protocol (MCP) server. MCP clients receive one `code` tool instead of every upstream tool. - -The `code` tool contains generated type definitions for the upstream tools. Model-written JavaScript can call several tools, process their results, and return one focused value. +A Code Mode MCP server lets any Model Context Protocol (MCP) client use model-written code without providing its own sandbox. Instead of advertising one tool per operation, the server exposes code execution as its tool interface and runs the generated JavaScript in an isolated Worker. Generated code can compose several operations and return only the final result, so tool definitions and intermediate data stay out of the model's context. For the design rationale, refer to [Code Mode: give agents an entire API in 1,000 tokens](https://blog.cloudflare.com/code-mode-mcp/). :::caution Code Mode is experimental and may have breaking changes. Use caution in production. ::: -## Prerequisites +## Choose a pattern -You need a Cloudflare Workers project and an existing `McpServer`. +| Pattern | MCP tools | Model-facing API | Use when | +| -------------------- | ------------------ | ------------------------------------- | ------------------------------------------------------------------------ | +| Single code tool | `code` | Typed methods for every upstream tool | You already have an MCP server with a manageable set of tools. | +| Search and execute | `search`, `execute` | OpenAPI document and request function | You have a large API whose complete schema should stay out of context. | + +Use `codeMcpServer()` when an existing MCP server already defines the operations and schemas the model needs. Use `openApiMcpServer()` when a large OpenAPI catalog needs progressive discovery and a fixed model-context footprint. + +Both functions currently return an SDK v1 server. Serve it through the explicit legacy `createLegacyMcpHandler` API, as the examples below do. + +## Wrap an existing MCP server + +The single-tool pattern wraps an existing MCP server with `codeMcpServer()`. Instead of advertising each upstream tool separately, the server advertises one `code` tool. + +The `code` tool description contains generated TypeScript definitions for every upstream tool. The model writes JavaScript against a `codemode` namespace: + +```js +async () => { + const projects = await codemode.list_projects({ status: "active" }); + const tasks = []; + + for (const project of projects) { + tasks.push(...(await codemode.list_tasks({ projectId: project.id }))); + } + + return tasks.filter((task) => task.status === "blocked"); +}; +``` -`codeMcpServer()` currently returns an SDK v1 server. Serve it through the explicit legacy `createLegacyMcpHandler` API. +The MCP client makes one outer tool call. Inside the sandbox, the code can make dependent upstream calls, filter intermediate data, and return only the final result. -## Wrap the server +This pattern works well when the generated type declarations fit comfortably in the `code` tool description. The model receives those declarations when it loads the MCP tool. + +You need a Cloudflare Workers project and an existing `McpServer`. @@ -140,18 +166,207 @@ When an upstream tool returns `structuredContent`, Code Mode exposes that value If you provide a custom `description`, use `{{types}}` where the generated TypeScript declarations should appear. Use `{{example}}` where the SDK should insert an example call based on the first upstream MCP tool. Both placeholders are optional. -## Protect upstream operations +### Protect upstream operations `codeMcpServer()` does not provide durable approval for each upstream tool call. It invokes upstream handlers from inside the outer `code` tool. Enforce authorization and any required per-operation approval in each upstream handler before applying side effects. Do not include credentials in tool results. -`DynamicWorkerExecutor` blocks external `fetch()` and `connect()` calls by default. Generated code can reach external systems only through the upstream MCP tools. +## Publish a large API with search and execute -## Limit results +A large API can have thousands of operations. Including every operation in one tool description would still consume substantial context. The search-and-execute pattern separates capability discovery from authenticated API calls. + +The server exposes two MCP tools: + +- `search` runs generated code against an OpenAPI document. It returns only the operations, parameters, or schemas needed for the task. +- `execute` runs generated code with an authenticated request function. It can call the selected operations, compose responses, and return a focused result. + +The complete OpenAPI document stays inside the sandbox. Only what the search code returns enters the model context. + +Authentication stays in the host request callback. The generated code receives a request function, not the credential. + +The [Cloudflare API MCP server](/agents/model-context-protocol/cloudflare/servers-for-cloudflare/) uses this pattern to expose the Cloudflare API through `search` and `execute`. For the design rationale and context savings, refer to [Code Mode: give agents an entire API in 1,000 tokens](https://blog.cloudflare.com/code-mode-mcp/). + +You need a Cloudflare Workers project, an OpenAPI 3.x document, and a host-side method for authenticating API requests. + + + +1. Install Code Mode and the MCP dependencies: + + + +2. Add a Worker Loader binding and the `nodejs_compat` compatibility flag: + + + + ```toml + name = "openapi-codemode-mcp" + main = "src/server.ts" + upload_source_maps = true + compatibility_date = "$today" + compatibility_flags = ["nodejs_compat"] + + [[worker_loaders]] + binding = "LOADER" + ``` + + + +3. Load the OpenAPI document on the host. Create the MCP server with an authenticated `request` function: + + + + ```ts + import { DynamicWorkerExecutor } from "@cloudflare/codemode"; + import { openApiMcpServer } from "@cloudflare/codemode/mcp"; + import { createLegacyMcpHandler } from "agents/mcp"; + + const SPEC_URL = "https://api.example.com/openapi.json"; + const API_ORIGIN = "https://api.example.com"; + + let specCache: Record | undefined; -Model-written code can select, map, aggregate, or paginate upstream data before returning. This prevents large intermediate results from entering the model context. + async function loadSpec(): Promise> { + if (specCache) return specCache; + + const response = await fetch(SPEC_URL); + if (!response.ok) { + throw new Error(`OpenAPI request failed: ${response.status}`); + } + + specCache = (await response.json()) as Record; + return specCache; + } + + export default { + async fetch(request, env, ctx): Promise { + const authorization = request.headers.get("Authorization"); + if (!authorization?.startsWith("Bearer ")) { + return new Response("Bearer token required", { status: 401 }); + } + + const server = openApiMcpServer({ + spec: await loadSpec(), + executor: new DynamicWorkerExecutor({ loader: env.LOADER }), + name: "example-api", + version: "1.0.0", + request: async (options) => { + if (!options.path.startsWith("/")) { + throw new Error("API path must start with a slash"); + } + + const url = new URL(`${API_ORIGIN}${options.path}`); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const headers: Record = { Authorization: authorization }; + if (options.contentType) { + headers["Content-Type"] = options.contentType; + } else if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(url, { + method: options.method, + headers, + body: + options.body === undefined + ? undefined + : options.rawBody + ? (options.body as string) + : JSON.stringify(options.body), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + if (response.status === 204) return null; + + const responseType = response.headers.get("Content-Type") ?? ""; + return responseType.includes("application/json") + ? await response.json() + : await response.text(); + }, + }); + + return createLegacyMcpHandler(server, { route: "/mcp" })( + request, + env, + ctx, + ); + }, + } satisfies ExportedHandler; + ``` + + + +4. Deploy the Worker: + + + +5. In an MCP client, connect to `https://..workers.dev/mcp`. Include the bearer token required by your Worker. + +6. List the MCP tools. Verify that the server exposes `search` and `execute`. + + + +### Search the OpenAPI document + +Call `search` before `execute`. Search code can inspect the document without making API requests: + +```js +async () => { + const spec = await codemode.spec(); + return Object.entries(spec.paths) + .filter(([path]) => path.includes("/orders")) + .map(([path, operations]) => ({ + path, + methods: Object.keys(operations), + })); +}; +``` + +Local OpenAPI `$ref` values resolve inside the sandbox when code calls `codemode.spec()`. External references remain unresolved. + +### Call the API + +The `execute` tool includes the same `codemode.spec()` method and the host-provided `codemode.request()` method: + +```js +async () => { + const response = await codemode.request({ + method: "GET", + path: "/orders", + query: { status: "processing", limit: 20 }, + }); + + return response.items.map(({ id, status }) => ({ id, status })); +}; +``` + +The host callback receives `method`, `path`, optional `query`, optional `body`, optional `contentType`, and optional `rawBody` fields. For exact types, refer to the [`openApiMcpServer()` API](/agents/tools/codemode/api-reference/#openapimcpserver). + +The `search` and `execute` tools use fixed example snippets. An optional `description` is appended to the `execute` tool description. This function does not use the `{{types}}` or `{{example}}` placeholders supported by [`codeMcpServer()`](#wrap-an-existing-mcp-server). + +### Protect the API + +The example reads the bearer token before creating the MCP server. Its request callback adds that token to outbound requests. The token never enters the sandbox. + +`openApiMcpServer()` does not provide durable approval for each request inside `execute`. Enforce authorization and any required per-operation approval in the host callback before applying side effects. Validate paths instead of accepting arbitrary origins. + +Do not include secrets in the OpenAPI document or API results. Both are available to model-written code. + +## Sandbox and authorization boundary + +Model-written code runs in an isolated Worker. `DynamicWorkerExecutor` blocks direct outbound `fetch()` and `connect()` calls by default, so generated code reaches external systems only through upstream MCP tools or the host-provided request callback. + +Code execution does not replace authorization. Enforce permissions and any required approval inside upstream tool handlers or the host request callback before applying side effects. Do not expose credentials through tool results or OpenAPI documents. + +## Limit results -The publisher limits the final MCP response to approximately 6,000 estimated tokens. A larger response is cut off and includes a `--- TRUNCATED ---` marker. This does not reduce work already performed by upstream tools. +Have model-written code select, map, aggregate, or paginate data before returning. The publisher limits final MCP responses to approximately 6,000 estimated tokens and marks truncated responses with `--- TRUNCATED ---`. -To publish an OpenAPI service with separate `search` and `execute` tools, refer to [Build a search and execute MCP server](/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/). +Truncation does not reduce API work already performed. Return focused identifiers, status fields, counts, and errors that support the model's next decision. diff --git a/src/content/docs/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server.mdx b/src/content/docs/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server.mdx deleted file mode 100644 index 7cce742ca63..00000000000 --- a/src/content/docs/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server.mdx +++ /dev/null @@ -1,216 +0,0 @@ ---- -title: Build a search and execute MCP server -description: Create Code Mode search and execute MCP tools from an OpenAPI document while keeping credentials in the host Worker. -pcx_content_type: how-to -sidebar: - order: 15 -tags: - - AI - - MCP -products: - - agents ---- - -import { - PackageManagers, - Steps, - TypeScriptExample, - WranglerConfig, -} from "~/components"; - -Use `openApiMcpServer()` to publish a large OpenAPI service through two Model Context Protocol (MCP) tools: - -- `search` runs model-written code against the OpenAPI document. -- `execute` adds a host-provided `codemode.request()` function. - -The OpenAPI document stays outside the model context unless search code returns part of it. Authentication remains in the host Worker. - -:::caution -Code Mode is experimental and may have breaking changes. Use caution in production. -::: - -## Prerequisites - -You need a Cloudflare Workers project, an OpenAPI 3.x document, and a host-side method for authenticating API requests. - -`openApiMcpServer()` currently returns an SDK v1 server. Serve it through the explicit legacy `createLegacyMcpHandler` API. - -## Publish the service - - - -1. Install Code Mode and the MCP dependencies: - - - -2. Add a Worker Loader binding and the `nodejs_compat` compatibility flag: - - - - ```toml - name = "openapi-codemode-mcp" - main = "src/server.ts" - upload_source_maps = true - compatibility_date = "$today" - compatibility_flags = ["nodejs_compat"] - - [[worker_loaders]] - binding = "LOADER" - ``` - - - -3. Load the OpenAPI document on the host. Create the MCP server with an authenticated `request` function: - - - - ```ts - import { DynamicWorkerExecutor } from "@cloudflare/codemode"; - import { openApiMcpServer } from "@cloudflare/codemode/mcp"; - import { createLegacyMcpHandler } from "agents/mcp"; - - const SPEC_URL = "https://api.example.com/openapi.json"; - const API_ORIGIN = "https://api.example.com"; - - let specCache: Record | undefined; - - async function loadSpec(): Promise> { - if (specCache) return specCache; - - const response = await fetch(SPEC_URL); - if (!response.ok) { - throw new Error(`OpenAPI request failed: ${response.status}`); - } - - specCache = (await response.json()) as Record; - return specCache; - } - - export default { - async fetch(request, env, ctx): Promise { - const authorization = request.headers.get("Authorization"); - if (!authorization?.startsWith("Bearer ")) { - return new Response("Bearer token required", { status: 401 }); - } - - const server = openApiMcpServer({ - spec: await loadSpec(), - executor: new DynamicWorkerExecutor({ loader: env.LOADER }), - name: "example-api", - version: "1.0.0", - request: async (options) => { - if (!options.path.startsWith("/")) { - throw new Error("API path must start with a slash"); - } - - const url = new URL(`${API_ORIGIN}${options.path}`); - for (const [key, value] of Object.entries(options.query ?? {})) { - if (value !== undefined) { - url.searchParams.set(key, String(value)); - } - } - - const headers: Record = { Authorization: authorization }; - if (options.contentType) { - headers["Content-Type"] = options.contentType; - } else if (options.body !== undefined) { - headers["Content-Type"] = "application/json"; - } - - const response = await fetch(url, { - method: options.method, - headers, - body: - options.body === undefined - ? undefined - : options.rawBody - ? (options.body as string) - : JSON.stringify(options.body), - }); - - if (!response.ok) { - throw new Error(`API request failed: ${response.status}`); - } - if (response.status === 204) return null; - - const responseType = response.headers.get("Content-Type") ?? ""; - return responseType.includes("application/json") - ? await response.json() - : await response.text(); - }, - }); - - return createLegacyMcpHandler(server, { route: "/mcp" })( - request, - env, - ctx, - ); - }, - } satisfies ExportedHandler; - ``` - - - -4. Deploy the Worker: - - - -5. In an MCP client, connect to `https://..workers.dev/mcp`. Include the bearer token required by your Worker. - -6. List the MCP tools. Verify that the server exposes `search` and `execute`. - - - -## Search the OpenAPI document - -Call `search` before `execute`. Search code can inspect the document without making API requests: - -```js -async () => { - const spec = await codemode.spec(); - return Object.entries(spec.paths) - .filter(([path]) => path.includes("/orders")) - .map(([path, operations]) => ({ - path, - methods: Object.keys(operations), - })); -}; -``` - -Local OpenAPI `$ref` values resolve inside the sandbox when code calls `codemode.spec()`. External references remain unresolved. - -## Call the API - -The `execute` tool includes the same `codemode.spec()` method and the host-provided `codemode.request()` method: - -```js -async () => { - const response = await codemode.request({ - method: "GET", - path: "/orders", - query: { status: "processing", limit: 20 }, - }); - - return response.items.map(({ id, status }) => ({ id, status })); -}; -``` - -The host callback receives `method`, `path`, optional `query`, optional `body`, optional `contentType`, and optional `rawBody` fields. For exact types, refer to the [`openApiMcpServer()` API](/agents/tools/codemode/api-reference/#openapimcpserver). - -The `search` and `execute` tools use fixed example snippets. An optional `description` is appended to the `execute` tool description. This function does not use the `{{types}}` or `{{example}}` placeholders supported by [`codeMcpServer()`](/agents/model-context-protocol/guides/build-codemode-mcp-server/). - -## Protect the API - -The example reads the bearer token before creating the MCP server. Its request callback adds that token to outbound requests. The token never enters the sandbox. - -`openApiMcpServer()` does not provide durable approval for each request inside `execute`. Enforce authorization and any required per-operation approval in the host callback before applying side effects. Validate paths instead of accepting arbitrary origins. - -Do not include secrets in the OpenAPI document or API results. Both are available to model-written code. - -`DynamicWorkerExecutor` blocks direct external `fetch()` and `connect()` calls by default. Generated code reaches the service only through the host request callback. - -## Limit results - -Have model-written code select, map, aggregate, or paginate data before returning. The publisher limits final MCP responses to approximately 6,000 estimated tokens and marks truncated responses with `--- TRUNCATED ---`. - -Truncation does not reduce API work already performed. Return focused identifiers, status fields, counts, and errors that support the model's next decision. diff --git a/src/content/docs/agents/model-context-protocol/guides/remote-mcp-server.mdx b/src/content/docs/agents/model-context-protocol/guides/remote-mcp-server.mdx index f18148b2611..a7d5478b3b5 100644 --- a/src/content/docs/agents/model-context-protocol/guides/remote-mcp-server.mdx +++ b/src/content/docs/agents/model-context-protocol/guides/remote-mcp-server.mdx @@ -17,25 +17,14 @@ This guide shows how to deploy a remote MCP server on Cloudflare using [Streamab - **Without authentication** — anyone can connect and use the server (no login required). - **With [authentication and authorization](/agents/model-context-protocol/guides/remote-mcp-server/#add-authentication)** — users sign in before accessing tools, and you can control which tools an agent can call based on the user's permissions. -## Choosing an approach +## How the server works -The Agents SDK provides multiple ways to create MCP servers. Choose the approach that fits your use case: +The templates in this guide build the server with [`createMcpHandler`](/agents/model-context-protocol/apis/handler-api/) from the Agents SDK. The handler is stateless: each request gets a fresh `McpServer` from a factory function, so the server needs no Durable Object or session. Keep data that must outlive a request in a binding such as KV, D1, or a Durable Object. -| Approach | Stateful? | Protocol path | Best for | -| ----------------------------------------------------------------------------------------------------- | -------------------- | ----------------------------------- | ------------------------------------------- | -| [`createMcpHandler()`](/agents/model-context-protocol/apis/handler-api/) | No | stateless with legacy compatibility | New stateless tools | -| [`createLegacyMcpHandler()`](/agents/model-context-protocol/apis/handler-api/#createlegacymcphandler) | Optional | legacy | Temporary existing `WorkerTransport` routes | -| [`McpAgent`](/agents/model-context-protocol/apis/agent-api/) | Yes | legacy | Deprecated Durable Object and RPC servers | -| Raw SDK transport | Depends on transport | Depends on SDK package | Custom transport ownership | - -Use `createMcpHandler` for a new stateless server. An existing `McpAgent` without legacy stateful dependencies can migrate directly. If it uses MCP session state, RPC, pushed requests, streams, or replay, plan the stateless equivalents and serve stateless and legacy lanes during the transition. Refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/) for the staged rollout. +To move an existing `McpAgent` server, refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/). The deprecated API is documented in the [`McpAgent` reference](/agents/model-context-protocol/apis/agent-api/). ## Deploy your first MCP server -:::note[Template protocol path] -The quick-deploy templates in this section still use the deprecated `McpAgent` path. Do not use that path for a new server. Start with the [`mcp-worker` example](https://github.com/cloudflare/agents/tree/main/examples/mcp-worker). If an existing template deployment needs sessionful behavior, add a stateless route and follow the [migration guide](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/). -::: - You can start by deploying a [public MCP server](https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-authless) without authentication, then add user authentication and scoped authorization later. If you already know your server will require authentication, you can skip ahead to the [next section](/agents/model-context-protocol/guides/remote-mcp-server/#add-authentication). ### Via the dashboard @@ -155,163 +144,16 @@ For example, to connect from Claude Desktop: To learn how to use remote MCP servers with other MCP clients, refer to [Test a Remote MCP Server](/agents/model-context-protocol/guides/test-remote-mcp-server/). -## Add Authentication - -The public MCP server example you deployed earlier allows any client to connect and invoke tools without logging in. To add user authentication to your MCP server, you can integrate Cloudflare Access or a third-party service as the OAuth provider. Your MCP server handles secure login flows and issues access tokens that MCP clients can use to make authenticated tool calls. Users sign in with the OAuth provider and grant their AI agent permission to interact with the tools exposed by your MCP server, using scoped permissions. - -### Cloudflare Access OAuth - -You can configure your MCP server to require user authentication through Cloudflare Access. Cloudflare Access acts as an identity aggregator and verifies user emails, signals from your existing [identity providers](/cloudflare-one/integrations/identity-providers/) (such as GitHub or Google), and other attributes such as IP address or device certificates. When users connect to the MCP server, they will be prompted to log in to the configured identity provider and are only granted access if they pass your [Access policies](/cloudflare-one/access-controls/policies/#selectors). - -For a step-by-step deployment guide, refer to [Secure MCP servers with Access for SaaS](/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/). - -### Third-party OAuth - -You can connect your MCP server with any [OAuth provider](/agents/model-context-protocol/protocol/authorization/#2-third-party-oauth-provider) that supports the OAuth 2.0 specification, including GitHub, Google, Slack, [Stytch](/agents/model-context-protocol/protocol/authorization/#stytch), [Auth0](/agents/model-context-protocol/protocol/authorization/#auth0), [WorkOS](/agents/model-context-protocol/protocol/authorization/#workos), and more. - -The following example demonstrates how to use GitHub as an OAuth provider. - -#### Step 1 — Create a new MCP server - -Run the following command to create a new MCP server with GitHub OAuth: - - - -Now, you have the MCP server setup, with dependencies installed. Move into that project folder: - -```sh -cd my-mcp-server-github-auth -``` - -You'll notice that in the example MCP server, if you open `src/index.ts`, the primary difference is that the `defaultHandler` is set to the `GitHubHandler`: - -```ts ins="OAuthProvider.GitHubHandler" -import GitHubHandler from "./github-handler"; - -export default new OAuthProvider({ - apiRoute: "/mcp", - apiHandler: MyMCP.serve("/mcp"), - defaultHandler: GitHubHandler, - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", -}); -``` - -This ensures that your users are redirected to GitHub to authenticate. To get this working though, you need to create OAuth client apps in the steps below. - -#### Step 2 — Create an OAuth App - -You'll need to create two [GitHub OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) to use GitHub as an authentication provider for your MCP server — one for local development, and one for production. - -#### Step 2.1 — Create a new OAuth App for local development - -1. Navigate to [github.com/settings/developers](https://github.com/settings/developers) to create a new OAuth App with the following settings: - - **Application name**: `My MCP Server (local)` - - **Homepage URL**: `http://localhost:8788` - - **Authorization callback URL**: `http://localhost:8788/callback` - -2. For the OAuth app you just created, add the client ID of the OAuth app as `GITHUB_CLIENT_ID` and generate a client secret, adding it as `GITHUB_CLIENT_SECRET` to a `.env` file in the root of your project, which [will be used to set secrets in local development](/workers/configuration/secrets/). - - ```sh - touch .env - echo 'GITHUB_CLIENT_ID="your-client-id"' >> .env - echo 'GITHUB_CLIENT_SECRET="your-client-secret"' >> .env - cat .env - ``` - -3. Run the following command to start the development server: - - ```sh - npm start - ``` - - Your MCP server is now running on `http://localhost:8788/mcp`. - -4. In a new terminal, run the [MCP inspector](https://github.com/modelcontextprotocol/inspector). The MCP inspector is an interactive MCP client that allows you to connect to your MCP server and invoke tools from a web browser. - - ```sh - npx @modelcontextprotocol/inspector@latest - ``` - -5. Open the MCP inspector in your web browser: - - ```sh - open http://localhost:5173 - ``` - -6. In the inspector, enter the URL of your MCP server, `http://localhost:8788/mcp` - -7. In the main panel on the right, click the **OAuth Settings** button and then click **Quick OAuth Flow**. - - You should be redirected to a GitHub login or authorization page. After authorizing the MCP Client (the inspector) access to your GitHub account, you will be redirected back to the inspector. - -8. Click **Connect** in the sidebar and you should see the "List Tools" button, which will list the tools that your MCP server exposes. - -#### Step 2.2 — Create a new OAuth App for production - -You'll need to repeat [Step 2.1](#step-21--create-a-new-oauth-app-for-local-development) to create a new OAuth App for production. - -1. Navigate to [github.com/settings/developers](https://github.com/settings/developers) to create a new OAuth App with the following settings: - -- **Application name**: `My MCP Server (production)` -- **Homepage URL**: Enter the workers.dev URL of your deployed MCP server (ex: `worker-name.account-name.workers.dev`) -- **Authorization callback URL**: Enter the `/callback` path of the workers.dev URL of your deployed MCP server (ex: `worker-name.account-name.workers.dev/callback`) - -2. For the OAuth app you just created, add the client ID and client secret, using Wrangler CLI: - -```sh -npx wrangler secret put GITHUB_CLIENT_ID -``` - -```sh -npx wrangler secret put GITHUB_CLIENT_SECRET -``` - -```sh -npx wrangler secret put COOKIE_ENCRYPTION_KEY -``` - -Use any random string for `COOKIE_ENCRYPTION_KEY`, for example the output of `openssl rand -hex 32`. - - :::caution - When you create the first secret, Wrangler will ask if you want to create a new Worker. Submit "Y" to create a new Worker and save the secret. - ::: - -3. Set up a KV namespace - - a. Create the KV namespace: - - ```bash - npx wrangler kv namespace create "OAUTH_KV" - ``` - - b. Update the `wrangler.jsonc` file with the resulting KV ID: +## Add authentication - ```json - { - "kvNamespaces": [ - { - "binding": "OAUTH_KV", - "id": "" - } - ] - } - ``` +The server you deployed accepts any client. To require users to sign in, put it behind an OAuth authorization server built with [Workers OAuth Provider](https://github.com/cloudflare/workers-oauth-provider): -4. Deploy the MCP server to your Cloudflare `workers.dev` domain: +1. Build an authorization server Worker with `OAuthAuthorizationServer`. Its `/authorize` page signs users in, directly or through GitHub, Google, Cloudflare Access, or another identity provider. +2. Wrap your MCP server's `createMcpHandler` in an `OAuthResourceServer`, which checks each request's token with the authorization server over a Service Binding. - ```bash - npm run deploy - ``` +MCP clients find the authorization server from your MCP server's `401` response, sign the user in, and retry with a token. Your tools read the signed-in user with `getMcpAuthContext()`. -5. Connect to your server running at `worker-name.account-name.workers.dev/mcp` using the [AI Playground](https://playground.ai.cloudflare.com/), MCP Inspector, or [other MCP clients](/agents/model-context-protocol/guides/test-remote-mcp-server/), and authenticate with GitHub. +Refer to [Authorization](/agents/model-context-protocol/protocol/authorization/) for both Workers in full. ## Next steps @@ -324,5 +166,5 @@ Use any random string for `COOKIE_ENCRYPTION_KEY`, for example the output of `op diff --git a/src/content/docs/agents/model-context-protocol/guides/securing-mcp-server.mdx b/src/content/docs/agents/model-context-protocol/guides/securing-mcp-server.mdx index 7623477c3cc..b8692c3e378 100644 --- a/src/content/docs/agents/model-context-protocol/guides/securing-mcp-server.mdx +++ b/src/content/docs/agents/model-context-protocol/guides/securing-mcp-server.mdx @@ -10,267 +10,367 @@ products: - agents --- -import { TypeScriptExample, LinkCard } from "~/components"; +import { LinkCard, TypeScriptExample } from "~/components"; -MCP servers, like any web application, need to be secured so they can be used by trusted users without abuse. The MCP specification uses OAuth 2.1 for authentication between MCP clients and servers. +MCP servers, like any web application, need to be secured so they can be used by trusted users without abuse. The MCP specification uses OAuth 2.1 for authorization between MCP clients and servers. -This guide covers security best practices for MCP servers that act as OAuth proxies to third-party providers (like GitHub or Google). +This guide covers what to get right when you protect an MCP server with [Workers OAuth Provider](https://github.com/cloudflare/workers-oauth-provider): the authorization server's consent page, signing users in through a third-party provider, and the checks your MCP server makes on every request. Refer to [Authorization](/agents/model-context-protocol/protocol/authorization/) to set up the authorization server and the MCP server first. -## OAuth protection with workers-oauth-provider +## Consent page -Cloudflare's [`workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider) handles token management, client registration, and access token validation: +Every MCP client that connects to your server can register itself, so your `/authorize` page must ask the user whether this client may act for them before calling `completeAuthorization()`. The page must show the client's name, the scopes, and the hostname the tokens will be sent to, and it must not be possible to frame or forge it. - - -```ts -import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; -import { MyMCP } from "./mcp"; - -export default new OAuthProvider({ - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", - apiRoute: "/mcp", - apiHandler: MyMCP.serve("/mcp"), - defaultHandler: AuthHandler, -}); -``` - - - -## Consent dialog security - -When your MCP server proxies to third-party OAuth providers, you must implement your own consent dialog before forwarding users upstream. This prevents the "confused deputy" problem where attackers could exploit cached consent. +Workers OAuth Provider handles the security of the consent flow. Your code renders the page. -### CSRF protection - -Without CSRF protection, attackers can trick users into approving malicious OAuth clients. Use a random token stored in a secure cookie: +| API | What it does | +| --- | --- | +| `describeConsent(request)` | Returns what the page must show: `clientName`, `clientDomain` (the verified domain of a Client ID Metadata Document client), `redirectHost`, `redirectIsLoopback`, and `scope`. | +| `beginConsent(request)` | Stores the authorization request on the server and returns a single-use `handle` for your form, plus `headers` to send with the page. The headers set a browser-bound `__Host-` cookie and block framing. | +| `approveConsent(request, handle, options?)` | Checks the handle against the cookie and returns the stored authorization request. Pass `scope` with the scopes the user approved. | +| `denyConsent(request, handle)` | Returns headers that redirect the user back to the client with `access_denied`. | +| `isConsentRemembered(request, authRequest, remember)` | Returns `true` if the user already approved this client, redirect URI, and scopes. Pass `remember` to `approveConsent()` to record an approval. | ```ts -// Generate CSRF token when showing consent form -function generateCSRFProtection() { - const token = crypto.randomUUID(); - const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`; - return { token, setCookie }; -} - -// Validate CSRF token on form submission -function validateCSRFToken(formData: FormData, request: Request) { - const tokenFromForm = formData.get("csrf_token"); - const cookieHeader = request.headers.get("Cookie") || ""; - const tokenFromCookie = cookieHeader - .split(";") - .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN=")) - ?.split("=")[1]; - - if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) { - throw new Error("CSRF token mismatch"); - } +import { + AuthorizationError, + OAuthAuthorizationServer, + type ConsentDescription, +} from "@cloudflare/workers-oauth-provider"; +import { env, WorkerEntrypoint } from "cloudflare:workers"; +import { html } from "hono/html"; + +const SCOPES = ["mcp:read", "mcp:write"]; + +const authorizationServer = new OAuthAuthorizationServer({ + issuer: env.ISSUER, + resources: [env.MCP_SERVER_URL], + scopesSupported: SCOPES, + clientIdMetadataDocumentEnabled: true, +}); - // Clear cookie after use (one-time use) - return { - clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`, - }; +// Replace with your own sign-in. Return undefined when no one is signed in. +async function getSignedInUser( + request: Request, +): Promise<{ id: string } | undefined> { + return { id: "user-123" }; } -``` - - -Include the token as a hidden field in your consent form: - -```html - -``` - -### Input sanitization +// hono/html escapes every interpolated value, so client-supplied strings are safe. +function consentPage(details: ConsentDescription, handle: string) { + return html` +

Allow ${details.clientName} to access your account?

+

Access will be sent to ${details.redirectHost}.

+ ${details.redirectIsLoopback + ? html`

This sends access to an app on your computer.

` + : ""} +
+ + ${details.scope.map( + (scope) => + html``, + )} + + +
`; +} -User-controlled content (client names, logos, URIs) can execute malicious scripts if not sanitized: +async function authorize(request: Request, env: Env): Promise { + const oauth = authorizationServer.getOAuthApi(env); + const user = await getSignedInUser(request); + if (!user) return Response.redirect(new URL("/login", request.url).toString(), 302); + + if (request.method === "GET") { + const authRequest = await oauth.parseAuthRequest(request); + const details = await oauth.describeConsent(authRequest); + const consent = await oauth.beginConsent(authRequest); + consent.headers.set("Content-Type", "text/html; charset=utf-8"); + return new Response(await consentPage(details, consent.handle), { + headers: consent.headers, + }); + } - + const form = await request.formData(); + const handle = String(form.get("handle")); + if (form.get("decision") !== "approve") { + const denied = await oauth.denyConsent(request, handle); + return new Response(null, { status: 302, headers: denied.headers }); + } -```ts -function sanitizeText(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + const approved = await oauth.approveConsent(request, handle, { + scope: form.getAll("scope").map(String), + }); + const { redirectTo } = await oauth.completeAuthorization({ + request: approved.request, // from storage, not from the form + userId: user.id, + metadata: {}, + scope: approved.request.scope, + props: { userId: user.id }, + }); + approved.headers.set("Location", redirectTo); + return new Response(null, { status: 302, headers: approved.headers }); } -function sanitizeUrl(url: string): string { - if (!url) return ""; - try { - const parsed = new URL(url); - // Only allow http/https - reject javascript:, data:, file: - if (!["http:", "https:"].includes(parsed.protocol)) { - return ""; +export default class AuthServer extends WorkerEntrypoint { + async fetch(request: Request) { + if (new URL(request.url).pathname !== "/authorize") { + return authorizationServer.fetch(request, this.env, this.ctx); + } + try { + return await authorize(request, this.env); + } catch (error) { + if (!(error instanceof AuthorizationError)) throw error; + // Redirect to the client only when the library validated where to send it. + if (error.redirectTo) return Response.redirect(error.redirectTo, 302); + return new Response(error.description, { status: 400 }); } - return url; - } catch { - return ""; } -} -// Always sanitize before rendering -const clientName = sanitizeText(client.clientName); -const logoUrl = sanitizeText(sanitizeUrl(client.logoUri)); + validateToken(resource: string, token: string) { + return authorizationServer.validateToken(resource, token, this.env); + } +} ``` -### Content Security Policy +The form carries only the handle. The authorization request stays on the server, so a forged form cannot approve a different client or redirect URI. -CSP headers instruct browsers to block dangerous content: +`describeConsent()` returns the values but does not render HTML, and every string in it can come from the client. Render them with a template or JSX library that escapes output, such as Hono's JSX. - +Refer to [Building a consent page](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/consent-page.md) for a complete page and for which errors to redirect back to the client and which to render on your page. -```ts -function buildSecurityHeaders(setCookie: string, nonce?: string): HeadersInit { - const cspDirectives = [ - "default-src 'none'", - "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""), - "style-src 'self' 'unsafe-inline'", - "img-src 'self' https:", - "font-src 'self'", - "form-action 'self'", - "frame-ancestors 'none'", // Prevent clickjacking - "base-uri 'self'", - "connect-src 'self'", - ].join("; "); - - return { - "Content-Security-Policy": cspDirectives, - "X-Frame-Options": "DENY", - "X-Content-Type-Options": "nosniff", - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": setCookie, - }; -} -``` +## Signing in through a third-party provider - +If your server signs users in with GitHub, Google, or another provider, every MCP client reaches that provider through your one OAuth app. This is the [confused deputy problem](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#confused-deputy-problem): without per-client consent, an attacker's client can ride on a user's earlier approval at the provider. -## State handling +Ask for consent first, then hand the user to the provider: -Between the consent dialog and the OAuth callback, you need to ensure it is the same user. Use a state token stored in KV with a short expiration: +| API | What it does | +| --- | --- | +| `beginUpstream(authRequest, options)` | Call after `approveConsent()`. Stores the authorization request and your `data` (for example, a PKCE verifier), and returns a `state` value for the provider redirect, bound to the user's browser by a `__Host-` cookie. | +| `finishUpstream(request)` | Call in your callback. Checks `state` against the cookie and returns the original authorization request and your `data`. A provider code replayed in another browser is rejected. | +| `tokenExchangeCallback` | An `OAuthAuthorizationServer` option that runs on token refresh. Refresh the provider's token there, and throw `OAuthError("invalid_grant")` when the provider has revoked access. | ```ts -// Create state token before redirecting to upstream provider -async function createOAuthState(oauthReqInfo: AuthRequest, kv: KVNamespace) { - const stateToken = crypto.randomUUID(); - await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), { - expirationTtl: 600, // 10 minutes - }); - return { stateToken }; +import { + AuthorizationError, + OAuthAuthorizationServer, + type ConsentDescription, +} from "@cloudflare/workers-oauth-provider"; +import { env, WorkerEntrypoint } from "cloudflare:workers"; +import { html } from "hono/html"; + +const authorizationServer = new OAuthAuthorizationServer({ + issuer: env.ISSUER, + resources: [env.MCP_SERVER_URL], + scopesSupported: ["mcp:read"], + clientIdMetadataDocumentEnabled: true, +}); + +// hono/html escapes every interpolated value, so client-supplied strings are safe. +function consentPage(details: ConsentDescription, handle: string) { + return html` +

Allow ${details.clientName} to use your GitHub account?

+

Access will be sent to ${details.redirectHost}.

+
+ + + +
`; } -// Bind state to browser session with a hashed cookie -async function bindStateToSession(stateToken: string) { - const encoder = new TextEncoder(); - const hashBuffer = await crypto.subtle.digest( +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest( "SHA-256", - encoder.encode(stateToken), + new TextEncoder().encode(value), ); - const hashHex = Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - return { - setCookie: `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`, - }; + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); } -// Validate state in callback -async function validateOAuthState(request: Request, kv: KVNamespace) { - const url = new URL(request.url); - const stateFromQuery = url.searchParams.get("state"); - - if (!stateFromQuery) { - throw new Error("Missing state parameter"); +async function authorize(request: Request, env: Env): Promise { + const oauth = authorizationServer.getOAuthApi(env); + + // GET /authorize: ask for consent before sending the user to GitHub. + if (request.method === "GET") { + const authRequest = await oauth.parseAuthRequest(request); + const details = await oauth.describeConsent(authRequest); + const consent = await oauth.beginConsent(authRequest); + consent.headers.set("Content-Type", "text/html; charset=utf-8"); + return new Response(await consentPage(details, consent.handle), { + headers: consent.headers, + }); } - // Check state exists in KV - const storedData = await kv.get(`oauth:state:${stateFromQuery}`); - if (!storedData) { - throw new Error("Invalid or expired state"); + // POST /authorize: the user decided. + const form = await request.formData(); + const handle = String(form.get("handle")); + if (form.get("decision") !== "approve") { + const denied = await oauth.denyConsent(request, handle); + return new Response(null, { status: 302, headers: denied.headers }); } + const approved = await oauth.approveConsent(request, handle); - // Validate state matches session cookie - // ... (hash comparison logic) + const verifier = crypto.randomUUID() + crypto.randomUUID(); + const { state, headers } = await oauth.beginUpstream(approved.request, { + data: { verifier }, // returned at the callback, never sent to GitHub + headers: approved.headers, + }); - await kv.delete(`oauth:state:${stateFromQuery}`); - return JSON.parse(storedData); + const github = new URL("https://github.com/login/oauth/authorize"); + github.searchParams.set("client_id", env.GITHUB_CLIENT_ID); + github.searchParams.set("redirect_uri", new URL("/callback", request.url).toString()); + github.searchParams.set("scope", "read:user"); + github.searchParams.set("state", state); + github.searchParams.set("code_challenge", await sha256(verifier)); + github.searchParams.set("code_challenge_method", "S256"); + headers.set("Location", github.toString()); + return new Response(null, { status: 302, headers }); } -``` - -
-## Cookie security - -### Why use the `__Host-` prefix? +async function callback(request: Request, env: Env): Promise { + const oauth = authorizationServer.getOAuthApi(env); + const { request: original, data, headers } = + await oauth.finishUpstream<{ verifier: string }>(request); + + const code = new URL(request.url).searchParams.get("code"); + if (!code) return new Response("GitHub did not return a code", { status: 400 }); + + const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + code_verifier: data.verifier, + }), + }); + const { access_token } = await tokenResponse.json<{ access_token: string }>(); -The `__Host-` prefix prevents subdomain attacks, which is especially important on `*.workers.dev` domains: + const userResponse = await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${access_token}`, "User-Agent": "my-mcp-server" }, + }); + const user = await userResponse.json<{ id: number; login: string }>(); + + const { redirectTo } = await oauth.completeAuthorization({ + request: original, + userId: String(user.id), + metadata: {}, + scope: original.scope, + props: { login: user.login, githubToken: access_token }, // encrypted at rest + }); + headers.set("Location", redirectTo); + return new Response(null, { status: 302, headers }); +} -- Must be set with `Secure` flag (HTTPS only) -- Must have `Path=/` -- Must not have a `Domain` attribute +export default class AuthServer extends WorkerEntrypoint { + async fetch(request: Request) { + const { pathname } = new URL(request.url); + try { + if (pathname === "/authorize") return await authorize(request, this.env); + if (pathname === "/callback") return await callback(request, this.env); + } catch (error) { + if (!(error instanceof AuthorizationError)) throw error; + if (error.redirectTo) return Response.redirect(error.redirectTo, 302); + return new Response(error.description, { status: 400 }); + } + return authorizationServer.fetch(request, this.env, this.ctx); + } -Without `__Host-`, an attacker controlling `evil.workers.dev` could set cookies for your `mcp-server.workers.dev` domain. + validateToken(resource: string, token: string) { + return authorizationServer.validateToken(resource, token, this.env); + } +} +``` -### Multiple OAuth flows +
-If running multiple OAuth flows on the same domain, namespace your cookies: +Refer to [Signing in through another provider](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/upstream-sign-in.md) for the full flow, including refreshing and revoking the provider's tokens. -```txt -__Host-CSRF_TOKEN_GITHUB -__Host-CSRF_TOKEN_GOOGLE -__Host-APPROVED_CLIENTS_GITHUB -__Host-APPROVED_CLIENTS_GOOGLE -``` +## Checks on the MCP server -## Approved clients registry +`OAuthResourceServer` accepts only tokens issued for its own `resourceMetadata.resource`, so a token for another server is rejected even if the same authorization server issued it. Keep that URL exactly what clients connect to. -Maintain a registry of approved client IDs per user to avoid showing the consent dialog repeatedly: +`requiredScopes` is advertised to clients but not enforced. Check scopes in the handler, and return `insufficientScope()` when one is missing so the client can ask the user for it: ```ts -async function addApprovedClient( - request: Request, - clientId: string, - cookieSecret: string, -) { - const existingClients = - (await getApprovedClientsFromCookie(request, cookieSecret)) || []; - const updatedClients = [...new Set([...existingClients, clientId])]; - - const payload = JSON.stringify(updatedClients); - const signature = await signData(payload, cookieSecret); // HMAC-SHA256 - const cookieValue = `${signature}.${btoa(payload)}`; - - return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`; +import { + OAuthResourceServer, + insufficientScope, + type AuthorizationServerBinding, +} from "@cloudflare/workers-oauth-provider"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; + +type AuthProps = { userId: string }; + +interface Env { + AUTH_SERVER: AuthorizationServerBinding; +} + +function createServer() { + const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }); + const props = getMcpAuthContext()?.props as AuthProps; + + server.registerTool( + "whoami", + { description: "Return the signed-in user" }, + async () => ({ + content: [{ type: "text", text: props.userId }], + }), + ); + + return server; } + +const mcpHandler = createMcpHandler(createServer); + +export default new OAuthResourceServer({ + resourceMetadata: { + resource: env.MCP_SERVER_URL, // tokens for any other resource are rejected + authorization_servers: [env.AUTH_SERVER_URL], + }, + requiredScopes: ["mcp:read"], + validateToken: (env) => env.AUTH_SERVER.validateToken, + handler: { + fetch(request, env, ctx) { + // requiredScopes is advertised, not enforced: check it here. + if (!ctx.auth.scope.includes("mcp:read")) { + return insufficientScope(ctx.auth, ["mcp:read"]); + } + return mcpHandler(request, env, ctx); + }, + }, +}); ``` -When reading the cookie, verify the HMAC signature before trusting the data. If the client is not in the approved list, show the consent dialog. +Props can hold upstream access tokens. Do not log them or return them from tools. ## Security checklist -| Protection | Purpose | -| ------------------ | -------------------------------- | -| CSRF tokens | Prevent forged consent approvals | -| Input sanitization | Prevent XSS in consent dialogs | -| CSP headers | Block injected scripts | -| State binding | Prevent session fixation | -| `__Host-` cookies | Prevent subdomain attacks | -| HMAC signatures | Verify cookie integrity | +| Protection | Provided by | +| --------------------------------------------------- | ---------------------------------------------- | +| Per-client consent before `completeAuthorization()` | `beginConsent()`, `approveConsent()` | +| Consent form cannot be framed or forged | `beginConsent()` headers and single-use handle | +| Client metadata escaped on the consent page | Your page template | +| Provider `state` bound to the browser | `beginUpstream()`, `finishUpstream()` | +| Tokens accepted only for this MCP server | `OAuthResourceServer` | +| Scopes checked for each operation | Your handler, with `insufficientScope()` | ## Next steps diff --git a/src/content/docs/agents/model-context-protocol/index.mdx b/src/content/docs/agents/model-context-protocol/index.mdx index a50df326d3e..9ac8a988e07 100644 --- a/src/content/docs/agents/model-context-protocol/index.mdx +++ b/src/content/docs/agents/model-context-protocol/index.mdx @@ -18,8 +18,8 @@ You can build and deploy [Model Context Protocol (MCP)](https://modelcontextprot ### MCP Terminology -- **MCP Hosts**: AI assistants (like [Claude](https://claude.ai) or [Cursor](https://cursor.com)), AI agents, or applications that need to access external capabilities. -- **MCP Clients**: Clients embedded within the MCP hosts that connect to MCP servers and invoke tools. Each MCP client instance has a single connection to an MCP server. +- **Agents**: AI assistants (like [Claude](https://claude.ai) or [Cursor](https://cursor.com)), AI agents, or applications that need to access external capabilities. +- **MCP Clients**: Clients embedded within agents that connect to MCP servers and invoke tools. Each MCP client instance has a single connection to an MCP server. - **MCP Servers**: Applications that expose [tools](/agents/model-context-protocol/protocol/tools/), [prompts](https://modelcontextprotocol.io/docs/concepts/prompts), and [resources](https://modelcontextprotocol.io/docs/concepts/resources) that MCP clients can use. ### Remote vs. local MCP connections @@ -29,12 +29,11 @@ The MCP standard supports two modes of operation: - **Remote MCP connections**: MCP clients connect to MCP servers over the Internet, establishing a connection using [Streamable HTTP](/agents/model-context-protocol/protocol/transport/), and authorizing the MCP client access to resources on the user's account using [OAuth](/agents/model-context-protocol/protocol/authorization/). - **Local MCP connections**: MCP clients connect to MCP servers on the same machine, using [stdio](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) as a local transport method. -### Best Practices +### Best practices -- **Tool design**: Do not treat your MCP server as a wrapper around your full API schema. Instead, build tools that are optimized for specific user goals and reliable outcomes. Fewer, well-designed tools often outperform many granular ones, especially for agents with small context windows or tight latency budgets. -- **Scoped permissions**: Deploying several focused MCP servers, each with narrowly scoped permissions, reduces the risk of over-privileged access and makes it easier to manage and audit what each server is allowed to do. -- **Tool descriptions**: Detailed parameter descriptions help agents understand how to use your tools correctly — including what values are expected, how they affect behavior, and any important constraints. This reduces errors and improves reliability. -- **Evaluation tests**: Use evaluation tests ('evals') to measure the agent’s ability to use your tools correctly. Run these after any updates to your server or tool descriptions to catch regressions early and track improvements over time. +- **Keep tool definitions out of the context window**: MCP clients used to send every tool definition straight to the model, so a server with many tools filled the agent's context window. Newer clients use Code Mode instead: the model writes code that calls your tools and loads only the definitions it needs. You can apply the same pattern on the server, exposing a large API through a small search-and-execute interface. Refer to [Code Mode for MCP servers](https://blog.cloudflare.com/code-mode-mcp/) and [Build a Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/). +- **Use granular authorization**: Protect your server with OAuth, and give each operation its own scopes. When a token lacks a scope, return an `insufficientScope()` challenge so the client asks the user to approve it (step-up authorization) instead of requesting every scope up front. Refer to [Authorization](/agents/model-context-protocol/protocol/authorization/). +- **Declare accurate input and output schemas**: Strict MCP clients validate tool calls against `inputSchema` and tool results against `outputSchema`, and reject tools whose results do not match. Define both schemas for every tool, and return `structuredContent` that matches `outputSchema`. Refer to [Tools](/agents/model-context-protocol/protocol/tools/). ### Get Started diff --git a/src/content/docs/agents/model-context-protocol/protocol/authorization.mdx b/src/content/docs/agents/model-context-protocol/protocol/authorization.mdx index 34023d9cda3..06c72128efb 100644 --- a/src/content/docs/agents/model-context-protocol/protocol/authorization.mdx +++ b/src/content/docs/agents/model-context-protocol/protocol/authorization.mdx @@ -1,6 +1,6 @@ --- title: Authorization -description: Add OAuth 2.1 authorization to your MCP server using Cloudflare Access, third-party providers, or your own identity system. +description: Protect an MCP server with OAuth 2.1 using an authorization server and a resource server built with Workers OAuth Provider. pcx_content_type: concept tags: - MCP @@ -10,88 +10,235 @@ products: - agents --- -import { LinkCard } from "~/components"; +import { + LinkCard, + PackageManagers, + TypeScriptExample, + WranglerConfig, +} from "~/components"; When building a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server, you need both a way to allow users to login (authentication) and allow them to grant the MCP client access to resources on their account (authorization). The Model Context Protocol uses [a subset of OAuth 2.1 for authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). OAuth allows your users to grant limited access to resources, without them having to share API keys or other credentials. -Cloudflare provides an [OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider) that implements the provider side of the OAuth 2.1 protocol, allowing you to easily add authorization to your MCP server. +Cloudflare's [Workers OAuth Provider](https://github.com/cloudflare/workers-oauth-provider) implements both sides of MCP authorization on Workers. A deployment has two roles: -You can use the OAuth Provider Library in four ways: +- The **authorization server** signs users in and issues access tokens. Build it with `OAuthAuthorizationServer`. +- The **resource server** is your MCP server. It accepts those tokens and checks each one with the authorization server over a [Service Binding](/workers/runtime-apis/bindings/service-bindings/). Build it with `OAuthResourceServer` around [`createMcpHandler`](/agents/model-context-protocol/apis/handler-api/). -1. Use Cloudflare Access as an OAuth provider. -2. Integrate directly with a third-party OAuth provider, such as GitHub or Google. -3. Integrate with your own OAuth provider, including authorization-as-a-service providers you might already rely on, such as Stytch, Auth0, or WorkOS. -4. Your Worker handles authorization and authentication itself. Your MCP server, running on Cloudflare, handles the complete OAuth flow. +```mermaid +sequenceDiagram + participant C as MCP client + participant M as MCP server (resource server) + participant A as Authorization server + + C->>M: MCP request without a token + M->>C: 401, pointing at the protected resource metadata + C->>A: Sign in and authorize (/authorize, /oauth/token) + A->>C: Access token for the MCP server + C->>M: MCP request with the access token + M->>A: validateToken() over the Service Binding + A->>M: Token details and the props stored at sign-in + M->>C: MCP response +``` + +One authorization server can issue tokens for several MCP servers, and each can run in its own Worker. + +## Build the authorization server + +Create a Worker for the authorization server and install the library: + + + +The library serves discovery, client registration, and the token endpoint. The `/authorize` page is yours: it signs the user in, asks for consent, and calls `completeAuthorization()`. The `props` you pass there are encrypted and handed to your MCP server with every request. + + + +```ts +import { + AuthorizationError, + OAuthAuthorizationServer, + type AuthRequest, +} from "@cloudflare/workers-oauth-provider"; +import { env, WorkerEntrypoint } from "cloudflare:workers"; + +// Everything this server can grant. +const SCOPES = ["mcp:read", "mcp:write", "offline_access"]; + +const authorizationServer = new OAuthAuthorizationServer({ + issuer: env.ISSUER, // this Worker's URL + resources: [env.MCP_SERVER_URL], // the MCP servers it issues tokens for + scopesSupported: SCOPES, + clientIdMetadataDocumentEnabled: true, + // Optional: Dynamic Client Registration for older MCP clients. + clientRegistrationEndpoint: "/oauth/register", +}); + +async function authorize(request: Request, env: Env): Promise { + const oauth = authorizationServer.getOAuthApi(env); + + let authRequest: AuthRequest; + try { + // Validates the client, redirect URI, resource, and PKCE. + authRequest = await oauth.parseAuthRequest(request); + } catch (error) { + if (!(error instanceof AuthorizationError)) throw error; + if (error.redirectTo) return Response.redirect(error.redirectTo, 302); + return new Response(error.description, { status: 400 }); + } + + // Replace with your own sign-in and consent page. + const user = { id: "user-123", name: "Ada" }; + + const { redirectTo } = await oauth.completeAuthorization({ + request: authRequest, + userId: user.id, + metadata: {}, + scope: authRequest.scope.filter((scope) => SCOPES.includes(scope)), + props: { userId: user.id, name: user.name }, + }); + return Response.redirect(redirectTo, 302); +} + +export default class AuthServer extends WorkerEntrypoint { + fetch(request: Request) { + if (new URL(request.url).pathname === "/authorize") { + return authorize(request, this.env); + } + return authorizationServer.fetch(request, this.env, this.ctx); + } + + // MCP servers call this over their Service Binding. + validateToken(resource: string, token: string) { + return authorizationServer.validateToken(resource, token, this.env); + } +} +``` + + + +The authorization server stores grants and tokens in a KV namespace bound as `OAUTH_KV`. Create one with `npx wrangler kv namespace create OAUTH_KV`. The `global_fetch_strictly_public` flag is required for Client ID Metadata Documents, because the server fetches client-supplied URLs. + + + +```jsonc +{ + "name": "auth-server", + "main": "src/index.ts", + "compatibility_date": "$today", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "vars": { + "ISSUER": "https://auth-server..workers.dev", + "MCP_SERVER_URL": "https://mcp-server..workers.dev/mcp", + }, + "kv_namespaces": [{ "binding": "OAUTH_KV", "id": "" }], +} +``` + + + +## Make your MCP server a resource server -The following sections describe each of these options and link to runnable code examples for each. +Wrap your `createMcpHandler` handler in an `OAuthResourceServer`. It publishes the protected resource metadata that MCP clients use to find your authorization server, answers requests without a valid token with a `401`, and rejects tokens issued for any other resource. Your handler runs only for valid tokens. -## Authorization options + -### (1) Cloudflare Access OAuth provider +```ts +import { + OAuthResourceServer, + insufficientScope, + type AuthorizationServerBinding, +} from "@cloudflare/workers-oauth-provider"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; -Cloudflare Access allows you to add Single Sign-On (SSO) functionality to your MCP server. Users authenticate to your MCP server using a [configured identity provider](/cloudflare-one/integrations/identity-providers/) or a [one-time PIN](/cloudflare-one/integrations/identity-providers/one-time-pin/), and they are only granted access if their identity matches your [Access policies](/cloudflare-one/access-controls/policies/). +type AuthProps = { userId: string; name: string }; -To deploy an [example MCP server](https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-cf-access) with Cloudflare Access as the OAuth provider, refer to [Secure MCP servers with Access for SaaS](/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/). +interface Env { + AUTH_SERVER: AuthorizationServerBinding; + AUTH_SERVER_URL: string; + MCP_SERVER_URL: string; +} -### (2) Third-party OAuth Provider +function createServer() { + const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }); + const props = getMcpAuthContext()?.props as AuthProps; -The [OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider) can be configured to use a third-party OAuth provider, such as GitHub or Google. You can see a complete example of this in the [GitHub example](/agents/model-context-protocol/guides/remote-mcp-server/#add-authentication). + server.registerTool( + "whoami", + { description: "Return the signed-in user" }, + async () => ({ + content: [{ type: "text", text: `Hello, ${props.name}!` }], + }), + ); -When you use a third-party OAuth provider, you must provide a handler to the `OAuthProvider` that implements the OAuth flow for the third-party provider. + return server; +} -```ts ins="defaultHandler: MyAuthHandler," -import MyAuthHandler from "./auth-handler"; +const mcpHandler = createMcpHandler(createServer); -export default new OAuthProvider({ - apiRoute: "/mcp", - // Your MCP server: - apiHandler: MyMCPServer.serve("/mcp"), - // Replace this handler with your own handler for authentication and authorization with the third-party provider: - defaultHandler: MyAuthHandler, - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", +export default new OAuthResourceServer({ + resourceMetadata: { + resource: env.MCP_SERVER_URL, // the URL MCP clients connect to + authorization_servers: [env.AUTH_SERVER_URL], + }, + // Scopes every client must ask for. Advertised, not enforced: check them in the handler. + requiredScopes: ["mcp:read"], + validateToken: (env) => env.AUTH_SERVER.validateToken, + handler: { + fetch(request, env, ctx) { + if (!ctx.auth.scope.includes("mcp:read")) { + return insufficientScope(ctx.auth, ["mcp:read"]); + } + // ctx.props is what completeAuthorization() stored. + // createMcpHandler exposes it to tools through getMcpAuthContext(). + return mcpHandler(request, env, ctx); + }, + }, }); ``` -Note that as [defined in the Model Context Protocol specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-flow-steps) when you use a third-party OAuth provider, the MCP Server (your Worker) generates and issues its own token to the MCP client: + -```mermaid -sequenceDiagram - participant B as User-Agent (Browser) - participant C as MCP Client - participant M as MCP Server (your Worker) - participant T as Third-Party Auth Server - - C->>M: Initial OAuth Request - M->>B: Redirect to Third-Party /authorize - B->>T: Authorization Request - Note over T: User authorizes - T->>B: Redirect to MCP Server callback - B->>M: Authorization code - M->>T: Exchange code for token - T->>M: Third-party access token - Note over M: Generate bound MCP token - M->>B: Redirect to MCP Client callback - B->>C: MCP authorization code - C->>M: Exchange code for token - M->>C: MCP access token +The MCP server reaches the authorization server through a Service Binding, so token checks never leave Cloudflare's network and the MCP server needs no KV namespace of its own: + + + +```jsonc +{ + "name": "mcp-server", + "main": "src/index.ts", + "compatibility_date": "$today", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "AUTH_SERVER_URL": "https://auth-server..workers.dev", + "MCP_SERVER_URL": "https://mcp-server..workers.dev/mcp", + }, + "services": [{ "binding": "AUTH_SERVER", "service": "auth-server" }], +} ``` -Read the docs for the [Workers OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider) for more details. + + +Deploy the authorization server first, because the MCP server binds to it. Then deploy the MCP server and connect an MCP client to its `/mcp` URL. The client follows the `401` to the authorization server, signs the user in, and retries with a token. + +`MCP_SERVER_URL` must be exactly the URL clients connect to, because every token is bound to it. For local development, run `npx wrangler dev` in each Worker on a different port, and set the URLs to their `http://localhost` values in each Worker's `.dev.vars` file. The library accepts `http` only on loopback hosts. + +## Sign users in + +Your `/authorize` page should call `completeAuthorization()` only after it has signed the user in and the user has approved the client. The library includes helpers for both: + +- **Consent page**: `describeConsent()`, `beginConsent()`, `approveConsent()`, and `denyConsent()` build a consent page that cannot be framed or forged. Refer to [Building a consent page](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/consent-page.md). +- **Sign in with GitHub, Google, or another provider**: `beginUpstream()` and `finishUpstream()` send the user to the provider after consent, and finish when it redirects back. Store the provider's access token in `props` to call its API from your tools. Refer to [Signing in through another provider](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/upstream-sign-in.md). -### (3) Bring your own OAuth Provider +### Cloudflare Access -If your application already implements an OAuth Provider itself, or you use an authorization-as-a-service provider, you can use this in the same way that you would use a third-party OAuth provider, described above in [(2) Third-party OAuth Provider](#2-third-party-oauth-provider). +Cloudflare Access can sign users in to your MCP server. Users authenticate with a [configured identity provider](/cloudflare-one/integrations/identity-providers/) or a [one-time PIN](/cloudflare-one/integrations/identity-providers/one-time-pin/), and are only granted access if they match your [Access policies](/cloudflare-one/access-controls/policies/). Refer to [Secure MCP servers with Access for SaaS](/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/). -You can use the auth provider to: +### Identity providers -- Allow users to authenticate to your MCP server through email, social logins, SSO (single sign-on), and MFA (multi-factor authentication). -- Define scopes and permissions that directly map to your MCP tools. -- Present users with a consent page corresponding with the requested permissions. -- Enforce the permissions so that agents can only invoke permitted tools. +You can also sign users in with an identity provider you already use. It can define scopes that map to your tools and present its own consent page. #### Stytch @@ -127,162 +274,107 @@ Get started with a remote MCP server that uses [Descope](https://www.descope.com [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-server-descope-auth) -### (4) Your MCP Server handles authorization and authentication itself +For every option, refer to the [Workers OAuth Provider documentation](https://github.com/cloudflare/workers-oauth-provider#readme). If you run an existing `OAuthProvider` deployment, which combines both roles in one Worker, refer to the [1.x migration guide](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/migration-1.x.md). -Your MCP Server, using the [OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider), can handle the complete OAuth authorization flow, without any third-party involvement. - -The [Workers OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider) is a Cloudflare Worker that implements a [`fetch()` handler](/workers/runtime-apis/handlers/fetch/), and handles incoming requests to your MCP server. - -You provide your own handlers for your MCP Server's API, and authentication and authorization logic, and URI paths for the OAuth endpoints, as shown below: - -```ts -export default new OAuthProvider({ - apiRoute: "/mcp", - // Your MCP server: - apiHandler: MyMCPServer.serve("/mcp"), - // Your handler for authentication and authorization: - defaultHandler: MyAuthHandler, - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", -}); -``` - -Refer to the [getting started example](/agents/model-context-protocol/guides/remote-mcp-server/) for a complete example of the `OAuthProvider` in use, with a mock authentication flow. +## Using authentication context in tools -The authorization flow in this case works like this: +The `props` you passed to `completeAuthorization()` reach the resource server handler as `ctx.props`, and `createMcpHandler` exposes them to your server through `getMcpAuthContext()`. The verified token's scopes and client are on `ctx.auth` in the handler. -```mermaid -sequenceDiagram - participant B as User-Agent (Browser) - participant C as MCP Client - participant M as MCP Server (your Worker) - - C->>M: MCP Request - M->>C: HTTP 401 Unauthorized - Note over C: Generate code_verifier and code_challenge - C->>B: Open browser with authorization URL + code_challenge - B->>M: GET /authorize - Note over M: User logs in and authorizes - M->>B: Redirect to callback URL with auth code - B->>C: Callback with authorization code - C->>M: Token Request with code + code_verifier - M->>C: Access Token (+ Refresh Token) - C->>M: MCP Request with Access Token - Note over C,M: Begin standard MCP message exchange -``` +The `whoami` tool in the [MCP server example](#make-your-mcp-server-a-resource-server) reads `props.name` this way. -Remember — [authentication is different from authorization](https://www.cloudflare.com/learning/access-management/authn-vs-authz/). Your MCP Server can handle authorization itself, while still relying on an external authentication service to first authenticate users. The [example](/agents/model-context-protocol/guides/remote-mcp-server/) in getting started provides a mock authentication flow. You will need to implement your own authentication handler — either handling authentication yourself, or using an external authentication services. +Props often hold upstream access tokens. Do not log or return them. -## Using authentication context in tools - -When a user authenticates through the OAuth Provider, their identity information is available inside your tools. How you access it depends on whether you use `McpAgent` or `createMcpHandler`. +## Permission-based tool access -### With `McpAgent` during migration +You can control which tools are available based on user permissions. There are two approaches: check permissions inside the tool handler, or register tools only for users who can use them. `getMcpAuthContext()` works inside the server factory, so the factory can decide which tools to register for each request. -This pattern applies only to existing deprecated `McpAgent` routes. The third type parameter defines the authentication context shape. Access it through `this.props` inside `init()` and tool handlers. + ```ts -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - -type AuthContext = { - claims: { sub: string; name: string; email: string }; - permissions: string[]; -}; +import { + OAuthResourceServer, + insufficientScope, + type AuthorizationServerBinding, +} from "@cloudflare/workers-oauth-provider"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; -export class MyMCP extends McpAgent { - server = new McpServer({ name: "Auth Demo", version: "1.0.0" }); +// Stored with completeAuthorization() when the user signed in. +type AuthProps = { userId: string; permissions: string[] }; - async init() { - this.server.tool("whoami", "Get the current user", {}, async () => ({ - content: [{ type: "text", text: `Hello, ${this.props.claims.name}!` }], - })); - } +interface Env { + AUTH_SERVER: AuthorizationServerBinding; } -``` - -### With stateless `createMcpHandler` - -A compatible Workers OAuth Provider supplies standard token metadata at `context.http.authInfo`. Use `getMcpAuthContext()` for existing application props. - -```ts -import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; -import { McpServer } from "@modelcontextprotocol/server"; function createServer() { - const server = new McpServer({ name: "Auth Demo", version: "1.0.0" }); + const server = new McpServer({ name: "Permissions Demo", version: "1.0.0" }); + const props = getMcpAuthContext()?.props as AuthProps; server.registerTool( - "whoami", - { description: "Get the current user", inputSchema: {} }, - async (_args, context) => { - const auth = getMcpAuthContext(); - const name = (auth?.props.name as string) ?? "anonymous"; + "publicTool", + { description: "Available to all users" }, + async () => ({ + content: [{ type: "text", text: "Public result" }], + }), + ); + + // Check inside the handler: the model sees the tool and the denial. + server.registerTool( + "adminAction", + { description: "Requires admin permission" }, + async () => { + if (!props.permissions.includes("admin")) { + return { + isError: true, + content: [{ type: "text", text: "Permission denied: requires admin" }], + }; + } return { - content: [ - { - type: "text", - text: `${name}: ${context.http?.authInfo?.clientId}`, - }, - ], + content: [{ type: "text", text: "Admin action completed" }], }; }, ); + // Register conditionally: the model never sees the tool. + if (props.permissions.includes("special_feature")) { + server.registerTool( + "specialTool", + { description: "Special feature" }, + async () => ({ + content: [{ type: "text", text: "Special feature result" }], + }), + ); + } + return server; } -export default { - fetch(request, env, ctx) { - return createMcpHandler(createServer)(request, env, ctx); +const mcpHandler = createMcpHandler(createServer); + +export default new OAuthResourceServer({ + resourceMetadata: { + resource: env.MCP_SERVER_URL, + authorization_servers: [env.AUTH_SERVER_URL], + }, + requiredScopes: ["mcp:read"], + validateToken: (env) => env.AUTH_SERVER.validateToken, + handler: { + fetch(request, env, ctx) { + if (!ctx.auth.scope.includes("mcp:read")) { + return insufficientScope(ctx.auth, ["mcp:read"]); + } + return mcpHandler(request, env, ctx); + }, }, -} satisfies ExportedHandler; +}); ``` -Do not log or return the raw access token. - -## Permission-based tool access + -You can control which tools are available based on user permissions. There are two approaches: check permissions inside the tool handler, or conditionally register tools. - -```ts -export class MyMCP extends McpAgent { - server = new McpServer({ name: "Permissions Demo", version: "1.0.0" }); - - async init() { - this.server.tool("publicTool", "Available to all users", {}, async () => ({ - content: [{ type: "text", text: "Public result" }], - })); - - this.server.tool( - "adminAction", - "Requires admin permission", - {}, - async () => { - if (!this.props.permissions?.includes("admin")) { - return { - content: [ - { type: "text", text: "Permission denied: requires admin" }, - ], - }; - } - return { - content: [{ type: "text", text: "Admin action completed" }], - }; - }, - ); - - if (this.props.permissions?.includes("special_feature")) { - this.server.tool("specialTool", "Special feature", {}, async () => ({ - content: [{ type: "text", text: "Special feature result" }], - })); - } - } -} -``` +Checking inside the handler returns an error message to the LLM, which can explain the denial to the user. Conditionally registering tools means the LLM never sees tools the user cannot access, so it cannot try to call them. -Checking inside the handler returns an error message to the LLM, which can explain the denial to the user. Conditionally registering tools means the LLM never sees tools the user cannot access — it cannot attempt to call them at all. +To gate access on OAuth scopes instead, check `ctx.auth.scope` in the resource server handler and return `insufficientScope(ctx.auth, [...])` when a scope is missing. The client then asks the user for the extra scope and retries. To use scopes inside tools, store the granted scopes in `props` when you call `completeAuthorization()`. ## Next steps diff --git a/src/content/docs/agents/model-context-protocol/protocol/tools.mdx b/src/content/docs/agents/model-context-protocol/protocol/tools.mdx index 355187a259c..654be31f6bb 100644 --- a/src/content/docs/agents/model-context-protocol/protocol/tools.mdx +++ b/src/content/docs/agents/model-context-protocol/protocol/tools.mdx @@ -14,18 +14,18 @@ import { TypeScriptExample, LinkCard } from "~/components"; MCP tools are functions that an [MCP server](/agents/model-context-protocol/) exposes for clients to call. An LLM can invoke a tool to look up data, run a calculation, or call an API. The MCP server executes the tool and returns its result. -Use `@modelcontextprotocol/server` for a stateless `createMcpHandler` server. `McpAgent` is deprecated and feature-frozen. Existing `McpAgent` routes must keep using `@modelcontextprotocol/sdk` only while they migrate. +Define tools on an `McpServer` from `@modelcontextprotocol/server` and serve it with [`createMcpHandler`](/agents/model-context-protocol/apis/handler-api/). :::note[Experimental WebMCP adapter] -The Agents SDK also includes the experimental `agents/experimental/webmcp` adapter for bridging `McpAgent` tools to Chrome's native `navigator.modelContext` API. This API is under active development and may change between releases. +The Agents SDK also includes the experimental `agents/experimental/webmcp` adapter for bridging tools from a remote MCP server to Chrome's native `navigator.modelContext` API. This API is under active development and may change between releases. ::: ## Defining tools @@ -36,6 +36,7 @@ Use `server.registerTool()` to register a tool on a stateless `McpServer` instan ```ts import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; import { z } from "zod"; function createServer() { @@ -46,14 +47,27 @@ function createServer() { { description: "Add two numbers together", inputSchema: { a: z.number(), b: z.number() }, + outputSchema: { sum: z.number() }, + }, + async ({ a, b }) => { + const result = { sum: a + b }; + return { + structuredContent: result, + content: [{ type: "text", text: JSON.stringify(result) }], + }; }, - async ({ a, b }) => ({ - content: [{ type: "text", text: String(a + b) }], - }), ); return server; } + +const handler = createMcpHandler(createServer); + +export default { + fetch(request, env, ctx) { + return handler(request, env, ctx); + }, +} satisfies ExportedHandler; ``` @@ -67,27 +81,46 @@ Tool results are returned as an array of content parts. The most common type is ```ts -server.registerTool( - "lookup", - { - description: "Look up a user by ID", - inputSchema: { userId: z.string() }, - }, - async ({ userId }) => { - const user = await db.getUser(userId); +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; +import { z } from "zod"; + +function createServer() { + const server = new McpServer({ name: "Users", version: "1.0.0" }); + + server.registerTool( + "lookup", + { + description: "Look up a user by ID", + inputSchema: { userId: z.string() }, + }, + async ({ userId }) => { + const user = await env.USERS.get(userId, "json"); + + if (!user) { + return { + isError: true, + content: [{ type: "text", text: `User ${userId} not found` }], + }; + } - if (!user) { return { - isError: true, - content: [{ type: "text", text: `User ${userId} not found` }], + content: [{ type: "text", text: JSON.stringify(user, null, 2) }], }; - } + }, + ); + + return server; +} + +const handler = createMcpHandler(createServer); - return { - content: [{ type: "text", text: JSON.stringify(user, null, 2) }], - }; +export default { + fetch(request, env, ctx) { + return handler(request, env, ctx); }, -); +} satisfies ExportedHandler; ``` @@ -109,31 +142,60 @@ Tool inputs are defined as Zod schemas and validated automatically before the ha ```ts -server.registerTool( - "search", - { - description: "Search for documents by query", - inputSchema: { - query: z.string().describe("The search query"), - limit: z - .number() - .min(1) - .max(100) - .default(10) - .describe("Maximum number of results to return"), - category: z - .enum(["docs", "blog", "api"]) - .optional() - .describe("Filter by content category"), +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; +import { z } from "zod"; + +const DOCUMENTS = [ + { title: "Getting started", category: "docs" }, + { title: "Release notes", category: "blog" }, + { title: "REST reference", category: "api" }, +]; + +function createServer() { + const server = new McpServer({ name: "Search", version: "1.0.0" }); + + server.registerTool( + "search", + { + description: "Search for documents by title", + inputSchema: { + query: z.string().describe("Text to look for in document titles"), + limit: z + .number() + .min(1) + .max(100) + .default(10) + .describe("Maximum number of results to return"), + category: z + .enum(["docs", "blog", "api"]) + .optional() + .describe("Only return documents in this category"), + }, }, + async ({ query, limit, category }) => { + const results = DOCUMENTS.filter( + (doc) => + doc.title.toLowerCase().includes(query.toLowerCase()) && + (!category || doc.category === category), + ).slice(0, limit); + + return { + content: [{ type: "text", text: JSON.stringify(results) }], + }; + }, + ); + + return server; +} + +const handler = createMcpHandler(createServer); + +export default { + fetch(request, env, ctx) { + return handler(request, env, ctx); }, - async ({ query, limit, category }) => { - const results = await searchIndex(query, { limit, category }); - return { - content: [{ type: "text", text: JSON.stringify(results) }], - }; - }, -); +} satisfies ExportedHandler; ``` @@ -145,16 +207,15 @@ For stateless MCP servers, define tools inside a factory function and pass the s ```ts -import { createMcpHandler } from "agents/mcp/server"; import { McpServer } from "@modelcontextprotocol/server"; -import { z } from "zod"; +import { createMcpHandler } from "agents/mcp/server"; function createServer() { const server = new McpServer({ name: "My Tools", version: "1.0.0" }); server.registerTool( "ping", - { description: "Check if the server is alive", inputSchema: {} }, + { description: "Check if the server is alive" }, async () => ({ content: [{ type: "text", text: "pong" }], }), @@ -163,48 +224,60 @@ function createServer() { return server; } +const handler = createMcpHandler(createServer); + export default { fetch(request, env, ctx) { - return createMcpHandler(createServer)(request, env, ctx); + return handler(request, env, ctx); }, -} satisfies ExportedHandler; +} satisfies ExportedHandler; ``` -## Using tools with `McpAgent` +## Tools that need state -This section applies only to existing legacy routes during migration. Define their tools in the `init()` method of an [`McpAgent`](/agents/model-context-protocol/apis/agent-api/). Tools have access to the agent instance through `this`, so they can read and write state. +`createMcpHandler` builds a new server for every request, so a tool cannot keep data in memory between calls. Read and write a binding instead, using `env` from `cloudflare:workers`: ```ts -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; -export class MyMCP extends McpAgent { - server = new McpServer({ name: "Stateful Tools", version: "1.0.0" }); - - async init() { - this.server.tool( - "incrementCounter", - "Increment and return a counter", - {}, - async () => { - const count = (this.state?.count ?? 0) + 1; - this.setState({ count }); - return { - content: [{ type: "text", text: `Counter: ${count}` }], - }; - }, - ); - } +function createServer() { + const server = new McpServer({ name: "Counter", version: "1.0.0" }); + + server.registerTool( + "incrementCounter", + { description: "Increment and return your counter" }, + async () => { + const userId = String(getMcpAuthContext()?.props.userId ?? "anonymous"); + const count = Number((await env.COUNTERS.get(userId)) ?? 0) + 1; + await env.COUNTERS.put(userId, String(count)); + return { + content: [{ type: "text", text: `Counter: ${count}` }], + }; + }, + ); + + return server; } + +const handler = createMcpHandler(createServer); + +export default { + fetch(request, env, ctx) { + return handler(request, env, ctx); + }, +} satisfies ExportedHandler; ``` +For data that needs transactions or per-user logic, call a Durable Object or an [Agent](/agents/) from the tool. + ## Next steps - - ```ts -export default new OAuthProvider({ - apiRoute: "/mcp", - apiHandler: createMcpHandler(createServer), - // ... other OAuth configuration -}); -``` - - - -### Servers that need protocol sessions - -MCP has no protocol-level session on the stateless path. Applications can store durable business data behind a separate storage boundary. - -While migrating legacy sessions, existing servers can keep a temporary `createLegacyMcpHandler` with `WorkerTransport` or `McpAgent` route beside the new stateless route. These APIs support transport state, event replay, pushed elicitation, sampling, and roots requests. `McpAgent` is deprecated and feature-frozen. - -Add the stateless route before moving clients, and keep both lanes until existing sessions drain. Refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/) for the staged migration. Refer to [`McpAgent`: Stream resumability](/agents/model-context-protocol/apis/agent-api/#stream-resumability) for existing stream behavior. - -## RPC transport - -The **RPC transport** is designed for internal applications where your MCP server and agent are both running on Cloudflare — they can even run in the same Worker. It sends JSON-RPC messages directly over Cloudflare's [RPC bindings](/workers/runtime-apis/bindings/service-bindings/rpc/) without going over the public internet. - -- **Faster** — no network overhead, direct function calls between Durable Objects -- **Simpler** — no HTTP endpoints, no connection management -- **Internal only** — perfect for agents calling MCP servers within the same Worker - -RPC transport does not support authentication. Use Streamable HTTP for external connections that require OAuth. - -### Connect an Agent to an existing `McpAgent` through RPC - -:::caution[Deprecated server path] - -This section applies only to existing `McpAgent` deployments during migration. Do not create a new `McpAgent` server. Use a stateless `createMcpHandler` server instead. - -::: - -#### 1. Define your MCP server - -Create your `McpAgent` with the tools you want to expose: - - +import { + OAuthResourceServer, + insufficientScope, + type AuthorizationServerBinding, +} from "@cloudflare/workers-oauth-provider"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; -```ts -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; +type AuthProps = { userId: string }; -type State = { counter: number }; - -export class MyMCP extends McpAgent { - server = new McpServer({ name: "MyMCP", version: "1.0.0" }); - initialState: State = { counter: 0 }; - - async init() { - this.server.tool( - "add", - "Add to the counter", - { amount: z.number() }, - async ({ amount }) => { - this.setState({ counter: this.state.counter + amount }); - return { - content: [ - { - type: "text", - text: `Added ${amount}, total is now ${this.state.counter}`, - }, - ], - }; - }, - ); - } +interface Env { + AUTH_SERVER: AuthorizationServerBinding; } -``` - - - -#### 2. Connect your Agent to the MCP server - -In your `Agent`, call `addMcpServer()` with the Durable Object binding in `onStart()`: - - - -```ts -import { AIChatAgent } from "@cloudflare/ai-chat"; -export class Chat extends AIChatAgent { - async onStart(): Promise { - // Pass the DO namespace binding directly - await this.addMcpServer("my-mcp", this.env.MyMCP); - } - - async onChatMessage(onFinish) { - const allTools = this.mcp.getAITools(); +function createServer() { + const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" }); + const props = getMcpAuthContext()?.props as AuthProps; - const result = streamText({ - model, - tools: allTools, - // ... - }); + server.registerTool( + "whoami", + { description: "Return the signed-in user" }, + async () => ({ + content: [{ type: "text", text: props.userId }], + }), + ); - return createUIMessageStreamResponse({ stream: result }); - } + return server; } -``` - - - -RPC connections are automatically restored after Durable Object hibernation, just like HTTP connections. The binding name and props are persisted to storage so the connection can be re-established without any extra code. -For RPC transport, if `addMcpServer` is called with a name that already has an active connection, the existing connection is returned instead of creating a duplicate. For HTTP transport, deduplication matches on both server name and URL (refer to [MCP Client API](/agents/model-context-protocol/apis/client-api/) for details). This makes it safe to call in `onStart()`. +const mcpHandler = createMcpHandler(createServer); -#### 3. Configure Durable Object bindings - -In your `wrangler.jsonc`, define bindings for both Durable Objects: - -```jsonc -{ - "durable_objects": { - "bindings": [ - { "name": "Chat", "class_name": "Chat" }, - { "name": "MyMCP", "class_name": "MyMCP" }, - ], +export default new OAuthResourceServer({ + resourceMetadata: { + resource: env.MCP_SERVER_URL, // tokens for any other resource are rejected + authorization_servers: [env.AUTH_SERVER_URL], }, - "migrations": [ - { - "new_sqlite_classes": ["MyMCP", "Chat"], - "tag": "v1", + requiredScopes: ["mcp:read"], + validateToken: (env) => env.AUTH_SERVER.validateToken, + handler: { + fetch(request, env, ctx) { + // requiredScopes is advertised, not enforced: check it here. + if (!ctx.auth.scope.includes("mcp:read")) { + return insufficientScope(ctx.auth, ["mcp:read"]); + } + return mcpHandler(request, env, ctx); }, - ], -} -``` - -#### 4. Set up your Worker fetch handler - -Route requests to your Chat agent: - - - -```ts -import { routeAgentRequest } from "agents"; - -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { - const url = new URL(request.url); - - // Optionally expose the MCP server via HTTP as well - if (url.pathname.startsWith("/mcp")) { - return MyMCP.serve("/mcp").fetch(request, env, ctx); - } - - const response = await routeAgentRequest(request, env); - if (response) return response; - - return new Response("Not found", { status: 404 }); }, -} satisfies ExportedHandler; -``` - - - -### Passing props to the MCP server - -Since RPC transport does not have an OAuth flow, you can pass user context directly as props: - - - -```ts -await this.addMcpServer("my-mcp", this.env.MyMCP, { - props: { userId: "user-123", role: "admin" }, }); ``` -Your `McpAgent` can then access these props: - - - -```ts -export class MyMCP extends McpAgent< - Env, - State, - { userId?: string; role?: string } -> { - async init() { - this.server.tool("whoami", "Get current user info", {}, async () => { - const userId = this.props?.userId || "anonymous"; - const role = this.props?.role || "guest"; - - return { - content: [{ type: "text", text: `User ID: ${userId}, Role: ${role}` }], - }; - }); - } -} -``` - - - -Props are type-safe (TypeScript extracts the Props type from your `McpAgent` generic), persistent (stored in Durable Object storage), and available immediately before any tool calls are made. - -### Configuring RPC transport server timeout - -The RPC transport has a configurable timeout for waiting for tool responses. By default, the server waits **60 seconds** for a tool handler to respond. You can customize this by overriding `getRpcTransportOptions()` in your `McpAgent`: - - - -```ts -export class MyMCP extends McpAgent { - server = new McpServer({ name: "MyMCP", version: "1.0.0" }); - - protected getRpcTransportOptions() { - return { timeout: 120000 }; // 2 minutes - } - - async init() { - this.server.tool( - "long-running-task", - "A tool that takes a while", - { input: z.string() }, - async ({ input }) => { - await longRunningOperation(input); - return { - content: [{ type: "text", text: "Task completed" }], - }; - }, - ); - } -} -``` - - - -## Choosing a transport - -| Transport | Use when | Pros | Cons | -| ------------------- | ------------------------------------- | ---------------------------------------- | ------------------------------------- | -| **Streamable HTTP** | External MCP servers, production apps | Standard protocol, secure, supports auth | Slight network overhead | -| **RPC** | Internal agents on Cloudflare | Fastest, simplest setup | No auth, Durable Object bindings only | -| **SSE** | Compatibility with older clients | Backwards compatible | Deprecated, use Streamable HTTP | - -### Migrate from McpAgent - -If the endpoint does not use legacy stateful features, migrate directly to a stateless server factory from `@modelcontextprotocol/server` and pass it to `createMcpHandler`. +### State without protocol sessions -If it depends on MCP session state, RPC, pushed server-to-client requests, standalone streams, or replay, first design stateless equivalents. For example, move business state behind explicit application storage and replace pushed input requests with stateless elicitation. Serve stateless and legacy lanes together while clients migrate and existing sessions drain. +The stateless handler has no MCP session. Keep data that must survive between requests in a Durable Object, D1, KV, or R2, keyed by the authenticated user rather than an MCP session ID. -Refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/) for the feature mapping, dual-era routing, and rollout steps. +To move an existing `McpAgent` server, refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/). The deprecated API is documented in the [`McpAgent` reference](/agents/model-context-protocol/apis/agent-api/). ### Testing with MCP clients diff --git a/src/content/docs/agents/runtime/communication/routing.mdx b/src/content/docs/agents/runtime/communication/routing.mdx index 5c05010535b..07756e8227b 100644 --- a/src/content/docs/agents/runtime/communication/routing.mdx +++ b/src/content/docs/agents/runtime/communication/routing.mdx @@ -500,10 +500,6 @@ export default { For agent-specific initialization, use `getAgentByName` instead where you control exactly which agent receives the props. -:::note -For `McpAgent`, props are automatically stored and accessible via `this.props`. Refer to [MCP servers](/agents/model-context-protocol/apis/agent-api/) for details. -::: - ### Routing retry Use `routingRetry` with `getAgentByName()` when server-side code should retry transient Durable Object routing failures: diff --git a/src/content/docs/agents/runtime/lifecycle/get-current-agent.mdx b/src/content/docs/agents/runtime/lifecycle/get-current-agent.mdx index a135ea2b29a..706eaf1b0fa 100644 --- a/src/content/docs/agents/runtime/lifecycle/get-current-agent.mdx +++ b/src/content/docs/agents/runtime/lifecycle/get-current-agent.mdx @@ -193,11 +193,12 @@ Route the callback through a public method on the agent. Custom methods are wrap ```ts import { RpcTarget } from "cloudflare:workers"; +import { Agent, getCurrentAgent } from "agents"; class HostCallbackBridge extends RpcTarget { - agent: MyMcpAgent; + agent: MyAgent; - constructor(agent: MyMcpAgent) { + constructor(agent: MyAgent) { super(); this.agent = agent; } @@ -209,9 +210,9 @@ class HostCallbackBridge extends RpcTarget { } } -export class MyMcpAgent extends McpAgent { +export class MyAgent extends Agent { async handleSandboxCallback() { - const { agent } = getCurrentAgent(); + const { agent } = getCurrentAgent(); // `agent` is available again. } } @@ -221,8 +222,6 @@ export class MyMcpAgent extends McpAgent { Context restored this way has `connection`, `request`, and `email` unset. It is not tied to live client I/O. -Server-initiated MCP requests (`elicitInput`, `createMessage`, and `listRoots`) on `McpAgent` do not require this indirection because the MCP transport retains its owning agent. - ## API reference ### `getCurrentAgent()` diff --git a/src/content/docs/agents/tools/codemode/mcp.mdx b/src/content/docs/agents/tools/codemode/mcp.mdx index 6b96ac871d3..a12ef48cca2 100644 --- a/src/content/docs/agents/tools/codemode/mcp.mdx +++ b/src/content/docs/agents/tools/codemode/mcp.mdx @@ -13,14 +13,14 @@ import { PackageManagers, Steps, TypeScriptExample } from "~/components"; Use `McpConnector` to expose tools from an existing Model Context Protocol (MCP) client connection inside the Code Mode sandbox. The connector works with the durable runtime, including discovery, approvals, and execution history. -This page covers an Agent consuming an MCP server. To publish Code Mode as an MCP server, refer to [Code Mode MCP server patterns](/agents/model-context-protocol/codemode/). +This page covers an Agent consuming an MCP server. To publish Code Mode as an MCP server, refer to [Build a Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/). ## Prerequisites You need: - A project with the [durable Code Mode runtime](/agents/tools/codemode/durable-runtime/) configured. That setup provides the Worker Loader binding and the `CodemodeRuntime` export. -- An existing Agents SDK MCP connection. To create and authorize the connection, refer to the [McpClient API](/agents/model-context-protocol/apis/client-api/). +- An existing Agents SDK MCP connection. To create and authorize the connection, refer to the [MCP Client](/agents/model-context-protocol/apis/client-api/). diff --git a/src/content/docs/agents/tools/codemode/openapi.mdx b/src/content/docs/agents/tools/codemode/openapi.mdx index 0325f27041a..da3f56d0ebc 100644 --- a/src/content/docs/agents/tools/codemode/openapi.mdx +++ b/src/content/docs/agents/tools/codemode/openapi.mdx @@ -15,7 +15,7 @@ Use `OpenApiConnector` to expose an OpenAPI service inside a durable Code Mode r The model can discover methods with `codemode.search()` and request focused input types with `codemode.describe()`. The complete OpenAPI document does not need to enter the model context. -This page covers an Agent consuming an OpenAPI service. To publish an OpenAPI service to external MCP clients through `search` and `execute`, refer to [Build a search and execute MCP server](/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/). +This page covers an Agent consuming an OpenAPI service. To publish an OpenAPI service to external MCP clients through `search` and `execute`, refer to [Build a Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/#publish-a-large-api-with-search-and-execute). ## Prerequisites diff --git a/src/content/docs/agents/tools/payments/mpp/accept-payments.mdx b/src/content/docs/agents/tools/payments/mpp/accept-payments.mdx index c3f787bfe70..21525003def 100644 --- a/src/content/docs/agents/tools/payments/mpp/accept-payments.mdx +++ b/src/content/docs/agents/tools/payments/mpp/accept-payments.mdx @@ -89,7 +89,7 @@ Add `mppx` middleware when you control the Worker application: ## Charge for an MCP tool -Add the MPP transport to an [`McpAgent`](/agents/model-context-protocol/apis/agent-api/): +Charge for a tool call from an MCP server on Workers. `mppx` reads payment credentials through the MCP SDK v1 request context (`Transport.mcpSdk()`), so this server uses an SDK v1 `McpServer` with `createLegacyMcpHandler`. It is still stateless: the Worker builds a new server for each request and needs no Durable Object. @@ -97,7 +97,7 @@ Add the MPP transport to an [`McpAgent`](/agents/model-context-protocol/apis/age -2. Bind the `McpAgent` Durable Object in the Wrangler configuration: +2. Configure the Worker: @@ -107,14 +107,6 @@ Add the MPP transport to an [`McpAgent`](/agents/model-context-protocol/apis/age upload_source_maps = true compatibility_date = "$today" compatibility_flags = ["nodejs_compat"] - - [[durable_objects.bindings]] - name = "MCP_OBJECT" - class_name = "PaidMCP" - - [[migrations]] - tag = "v1" - new_sqlite_classes = ["PaidMCP"] ``` @@ -129,45 +121,50 @@ Add the MPP transport to an [`McpAgent`](/agents/model-context-protocol/apis/age ```ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - import { McpAgent } from "agents/mcp"; + import { createLegacyMcpHandler } from "agents/mcp"; import { Mppx, tempo, Transport } from "mppx/server"; import { z } from "zod"; type PaymentEnv = Env & { MPP_SECRET_KEY: string }; - export class PaidMCP extends McpAgent { - server = new McpServer({ name: "paid-search", version: "1.0.0" }); - - async init() { - const mppx = Mppx.create({ - methods: [tempo.charge({ testnet: true })], - secretKey: this.env.MPP_SECRET_KEY, - transport: Transport.mcpSdk(), - }); - - this.server.tool( - "premium_search", - "Search premium content", - { query: z.string() }, - async ({ query }, extra) => { - const payment = await mppx.charge({ - amount: "0.01", - currency: "0x20c0000000000000000000000000000000000000", - description: "Premium search", - recipient: "", - })(extra); - - if (payment.status === 402) throw payment.challenge; - - return payment.withReceipt({ - content: [{ type: "text", text: `Results for: ${query}` }], - }); - }, - ); - } + function createServer(env: PaymentEnv) { + const server = new McpServer({ name: "paid-search", version: "1.0.0" }); + const mppx = Mppx.create({ + methods: [tempo.charge({ testnet: true })], + secretKey: env.MPP_SECRET_KEY, + transport: Transport.mcpSdk(), + }); + + server.registerTool( + "premium_search", + { + description: "Search premium content", + inputSchema: { query: z.string() }, + }, + async ({ query }, extra) => { + const payment = await mppx.charge({ + amount: "0.01", + currency: "0x20c0000000000000000000000000000000000000", + description: "Premium search", + recipient: "", + })(extra); + + if (payment.status === 402) throw payment.challenge; + + return payment.withReceipt({ + content: [{ type: "text", text: `Results for: ${query}` }], + }); + }, + ); + + return server; } - export default PaidMCP.serve("/mcp"); + export default { + fetch(request, env, ctx) { + return createLegacyMcpHandler(createServer(env))(request, env, ctx); + }, + } satisfies ExportedHandler; ``` diff --git a/src/content/docs/agents/tools/payments/x402/charge-for-mcp-tools.mdx b/src/content/docs/agents/tools/payments/x402/charge-for-mcp-tools.mdx index 5243a05d424..e8891552a85 100644 --- a/src/content/docs/agents/tools/payments/x402/charge-for-mcp-tools.mdx +++ b/src/content/docs/agents/tools/payments/x402/charge-for-mcp-tools.mdx @@ -8,15 +8,19 @@ products: - agents --- -The Agents SDK provides `paidTool`, a drop-in replacement for `tool` that adds x402 payment requirements. Clients pay per tool call, and you can mix free and paid tools in the same server. +import { TypeScriptExample } from "~/components"; + +The Agents SDK provides `paidTool`, which registers a tool with x402 payment requirements. Clients pay per tool call, and you can mix free and paid tools in the same server. ## Setup Wrap your `McpServer` with `withX402` and use `paidTool` for tools you want to charge for: + + ```ts -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler } from "agents/mcp/server"; import { withX402, type X402Config } from "agents/x402"; import { z } from "zod"; @@ -27,38 +31,47 @@ const X402_CONFIG: X402Config = { // To learn more about facilitators: https://docs.x402.org/core-concepts/facilitator }; -export class PaidMCP extends McpAgent { - server = withX402( +function createServer() { + const server = withX402( new McpServer({ name: "PaidMCP", version: "1.0.0" }), X402_CONFIG, ); - async init() { - // Paid tool — $0.01 per call - this.server.paidTool( - "square", - "Squares a number", - 0.01, // USD - { number: z.number() }, - {}, - async ({ number }) => { - return { content: [{ type: "text", text: String(number ** 2) }] }; - }, - ); - - // Free tool - this.server.tool( - "echo", - "Echo a message", - { message: z.string() }, - async ({ message }) => { - return { content: [{ type: "text", text: message }] }; - }, - ); - } + // Paid tool — $0.01 per call + server.paidTool( + "square", + "Squares a number", + 0.01, // USD + { number: z.number() }, + {}, + async ({ number }) => { + return { content: [{ type: "text", text: String(number ** 2) }] }; + }, + ); + + // Free tool + server.registerTool( + "echo", + { description: "Echo a message", inputSchema: { message: z.string() } }, + async ({ message }) => { + return { content: [{ type: "text", text: message }] }; + }, + ); + + return server; } + +const handler = createMcpHandler(createServer); + +export default { + fetch(request, env, ctx) { + return handler(request, env, ctx); + }, +} satisfies ExportedHandler; ``` + + ## Configuration | Field | Description | @@ -67,18 +80,18 @@ export class PaidMCP extends McpAgent { | `recipient` | Wallet address to receive payments | | `facilitator` | Payment facilitator URL (use `https://x402.org/facilitator`) | -## paidTool signature +## paidTool parameters -```ts -this.server.paidTool( - name, // Tool name - description, // Tool description - price, // Price in USD (e.g., 0.01) - inputSchema, // Zod schema for inputs - annotations, // MCP annotations - handler, // Async function that executes the tool -); -``` +`paidTool()` takes the same arguments as registering a tool, plus a price: + +| Parameter | Description | +| ------------- | ----------------------------------------------- | +| `name` | Tool name | +| `description` | Tool description | +| `price` | Price per call in USD, for example `0.01` | +| `inputSchema` | Zod shape for the tool's input | +| `annotations` | MCP tool annotations | +| `handler` | Async function that runs after payment succeeds | When a client calls a paid tool without payment, the server returns 402 with payment requirements. The client pays via x402, retries with payment proof, and receives the result. diff --git a/src/content/docs/workers/demos/chatgpt-app.mdx b/src/content/docs/workers/demos/chatgpt-app.mdx index 9c8bcafebe8..e5ccd8ba0d7 100644 --- a/src/content/docs/workers/demos/chatgpt-app.mdx +++ b/src/content/docs/workers/demos/chatgpt-app.mdx @@ -67,7 +67,7 @@ cd my-chess-app 3. Install the required dependencies: ```sh -npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard +npm install agents @modelcontextprotocol/server zod chess.js react react-dom react-chessboard ``` 4. Install development dependencies: @@ -312,9 +312,9 @@ export class ChessGame extends Agent { 1. Create your main worker at `src/index.ts`: ```ts -import { createMcpHandler } from "agents/mcp"; +import { createMcpHandler } from "agents/mcp/server"; import { routeAgentRequest } from "agents"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { env } from "cloudflare:workers"; const getWidgetHtml = async (host: string) => { @@ -334,15 +334,13 @@ function createServer() { "chess", "ui://widget/index.html", {}, - async (_uri, extra) => { + async (_uri, ctx) => { return { contents: [ { uri: "ui://widget/index.html", mimeType: "text/html+skybridge", - text: await getWidgetHtml( - extra.requestInfo?.headers.host as string, - ), + text: await getWidgetHtml(ctx.http?.req?.headers.get("host") ?? ""), }, ], }; @@ -361,7 +359,7 @@ function createServer() { "openai/toolInvocation/invoked": "Chess widget opened", }, }, - async (_, _extra) => { + async () => { return { content: [ { type: "text", text: "Successfully rendered chess game menu" }, @@ -373,13 +371,14 @@ function createServer() { return server; } +// createMcpHandler builds a fresh server from the factory for every request +const mcpHandler = createMcpHandler(createServer); + export default { async fetch(req: Request, env: Env, ctx: ExecutionContext) { const url = new URL(req.url); - if (url.pathname.startsWith("/mcp")) { - // Create a new server instance per request - const server = createServer(); - return createMcpHandler(server)(req, env, ctx); + if (url.pathname === "/mcp") { + return mcpHandler(req, env, ctx); } return ( @@ -705,7 +704,7 @@ You might need to manually select the connector in the prompt box the first time ### MCP Server -The Model Context Protocol (MCP) server defines tools and resources that ChatGPT can access. Note that we create a new server instance per request to prevent cross-client response leakage: +The Model Context Protocol (MCP) server defines tools and resources that ChatGPT can access. `createMcpHandler` calls the `createServer` factory for every request, so clients never share a server instance: ```ts function createServer() { @@ -716,15 +715,13 @@ function createServer() { "chess", "ui://widget/index.html", {}, - async (_uri, extra) => { + async (_uri, ctx) => { return { contents: [ { uri: "ui://widget/index.html", mimeType: "text/html+skybridge", - text: await getWidgetHtml( - extra.requestInfo?.headers.host as string, - ), + text: await getWidgetHtml(ctx.http?.req?.headers.get("host") ?? ""), }, ], }; @@ -743,7 +740,7 @@ function createServer() { "openai/toolInvocation/invoked": "Chess widget opened", }, }, - async (_, _extra) => { + async () => { return { content: [ { type: "text", text: "Successfully rendered chess game menu" }, diff --git a/src/content/partials/agents/next-steps.mdx b/src/content/partials/agents/next-steps.mdx index 5b5c8fba6aa..b1a384f100f 100644 --- a/src/content/partials/agents/next-steps.mdx +++ b/src/content/partials/agents/next-steps.mdx @@ -13,7 +13,7 @@ Now that you have a working agent, explore these topics: | Learn how to | Refer to | | ------------------------ | --------------------------------------------------------- | | Add AI/LLM capabilities | [Using AI models](/agents/runtime/operations/using-ai-models/) | -| Expose tools via MCP | [MCP servers](/agents/model-context-protocol/apis/agent-api/) | +| Expose tools via MCP | [MCP servers](/agents/model-context-protocol/apis/handler-api/) | | Run background tasks | [Schedule tasks](/agents/runtime/execution/schedule-tasks/) | | Handle emails | [Email routing](/agents/communication-channels/email/) | | Use Cloudflare Workflows | [Run Workflows](/agents/runtime/execution/run-workflows/) |