From 2af5b921d689bf712adc18277fa3c14f2061e71e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 12:12:54 +0100 Subject: [PATCH 01/11] [Agents] Document MCP servers with createMcpHandler only --- public/__redirects | 5 +- src/content/docs/agent-lee/index.mdx | 14 +- .../agentic-patterns/human-in-the-loop.mdx | 6 +- .../model-context-protocol/apis/agent-api.mdx | 421 ------------------ .../apis/client-api.mdx | 38 +- .../apis/handler-api.mdx | 131 ++---- .../guides/remote-mcp-server.mdx | 34 +- .../guides/securing-mcp-server.mdx | 11 +- .../protocol/authorization.mdx | 121 +++-- .../model-context-protocol/protocol/tools.mdx | 53 +-- .../protocol/transport.mdx | 251 +---------- .../agents/runtime/communication/routing.mdx | 4 - .../runtime/lifecycle/get-current-agent.mdx | 11 +- .../tools/payments/mpp/accept-payments.mdx | 81 ++-- .../payments/x402/charge-for-mcp-tools.mdx | 65 +-- .../docs/workers/demos/chatgpt-app.mdx | 33 +- src/content/partials/agents/next-steps.mdx | 2 +- 17 files changed, 270 insertions(+), 1011 deletions(-) delete mode 100644 src/content/docs/agents/model-context-protocol/apis/agent-api.mdx diff --git a/public/__redirects b/public/__redirects index ea7b53a8412..a040063617e 100644 --- a/public/__redirects +++ b/public/__redirects @@ -258,7 +258,8 @@ /agents/api-reference/sdk/ /agents/runtime/agents-api/ 301 /agents/guides/build-mcp-server/ /agents/model-context-protocol/guides/remote-mcp-server/ 301 /agents/capabilities/mcp-server/ /agents/model-context-protocol/ 301 -/agents/model-context-protocol/mcp-agent-api/ /agents/model-context-protocol/apis/agent-api/ 301 +/agents/model-context-protocol/mcp-agent-api/ /agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/ 301 +/agents/model-context-protocol/apis/agent-api/ /agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/ 301 /agents/model-context-protocol/mcp-client-api/ /agents/model-context-protocol/apis/client-api/ 301 /agents/model-context-protocol/mcp-handler-api/ /agents/model-context-protocol/apis/handler-api/ 301 /agents/api-reference/calling-agents/ /agents/runtime/communication/routing/ 301 @@ -283,7 +284,7 @@ /agents/api-reference/email/ /agents/communication-channels/email/ 301 /agents/api-reference/get-current-agent/ /agents/runtime/lifecycle/get-current-agent/ 301 /agents/api-reference/http-sse/ /agents/runtime/communication/http-sse/ 301 -/agents/api-reference/mcp-agent-api/ /agents/model-context-protocol/apis/agent-api/ 301 +/agents/api-reference/mcp-agent-api/ /agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/ 301 /agents/api-reference/mcp-client-api/ /agents/model-context-protocol/apis/client-api/ 301 /agents/api-reference/mcp-handler-api/ /agents/model-context-protocol/apis/handler-api/ 301 /agents/api-reference/observability/ /agents/runtime/operations/observability/ 301 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/model-context-protocol/apis/agent-api.mdx b/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx deleted file mode 100644 index 9f140b05109..00000000000 --- a/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx +++ /dev/null @@ -1,421 +0,0 @@ ---- -pcx_content_type: reference -title: McpAgent -description: Reference the deprecated, feature-frozen McpAgent class while migrating existing stateful MCP servers to stateless handlers. -tags: - - MCP -sidebar: - order: 25 -products: - - agents ---- - -import { TypeScriptExample, LinkCard } from "~/components"; - -`McpAgent` creates a stateful legacy MCP server backed by a Durable Object. - -:::caution[Deprecated] - -`McpAgent` remains available only for existing legacy servers while they migrate. It is deprecated and feature-frozen. Migrate to [`createMcpHandler`](/agents/model-context-protocol/apis/handler-api/) at your earliest convenience. - -A server that depends on MCP session state, RPC, pushed server-to-client requests, standalone streams, or replay needs a staged migration. Design stateless equivalents, add a stateless route, and serve both lanes until clients migrate and existing sessions drain. - -Keep importing the legacy server `McpServer` from `@modelcontextprotocol/sdk`. An SDK v2 server from `@modelcontextprotocol/server` cannot run inside `McpAgent`. - -Refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/) for the feature mapping and dual-era rollout. - -::: - - - -```ts title="src/index.ts" -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -export class MyMCP extends McpAgent { - server = new McpServer({ name: "Demo", version: "1.0.0" }); - - async init() { - this.server.tool( - "add", - { a: z.number(), b: z.number() }, - async ({ a, b }) => ({ - content: [{ type: "text", text: String(a + b) }], - }), - ); - } -} -``` - - - -This means that each instance of your MCP server has its own durable state, backed by a [Durable Object](/durable-objects/), with its own [SQL database](/agents/runtime/lifecycle/state/). - -A stateless server can define [tools](/agents/model-context-protocol/protocol/tools/) with `@modelcontextprotocol/server` and serve them through `createMcpHandler`. - -But if you want your MCP server to: - -- remember previous tool calls, and responses it provided -- provide a game to the MCP client, remembering the state of the game board, previous moves, and the score -- cache the state of a previous external API call, so that subsequent tool calls can reuse it -- do anything that an Agent can do, but allow MCP clients to communicate with it - -You can use the APIs below in order to do so. - -## API overview - -| Property/Method | Description | -| ------------------------------- | -------------------------------------------------- | -| `state` | Current state object (persisted) | -| `initialState` | Default state when instance starts | -| `setState(state)` | Update and persist state | -| `onStateChanged(state)` | Called when state changes | -| `sql` | Execute SQL queries on embedded database | -| `server` | The `McpServer` instance for registering tools | -| `props` | User identity and tokens from OAuth authentication | -| `elicitInput(options, context)` | Request structured input from user | -| `McpAgent.serve(path, options)` | Static method to create a Worker handler | - -## Deploying with McpAgent.serve() - -The `McpAgent.serve()` static method creates a Worker handler that routes requests to your MCP server: - - - -```ts -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -export class MyMCP extends McpAgent { - server = new McpServer({ name: "my-server", version: "1.0.0" }); - - async init() { - this.server.tool("square", { n: z.number() }, async ({ n }) => ({ - content: [{ type: "text", text: String(n * n) }], - })); - } -} - -// Export the Worker handler -export default MyMCP.serve("/mcp"); -``` - - - -This is the simplest way to deploy an MCP server — about 15 lines of code. The `serve()` method handles Streamable HTTP transport automatically. - -### With OAuth authentication - -When using the [OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider), pass your MCP server to `apiHandlers`: - - - -```ts -import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; - -export default new OAuthProvider({ - apiHandlers: { "/mcp": MyMCP.serve("/mcp") }, - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", - defaultHandler: AuthHandler, -}); -``` - - - -## Data jurisdiction - -For GDPR and data residency compliance, specify a jurisdiction to ensure your MCP server instances run in specific regions: - - - -```ts -// EU jurisdiction for GDPR compliance -export default MyMCP.serve("/mcp", { jurisdiction: "eu" }); -``` - - - -With OAuth: - - - -```ts -export default new OAuthProvider({ - apiHandlers: { - "/mcp": MyMCP.serve("/mcp", { jurisdiction: "eu" }), - }, - // ... other OAuth config -}); -``` - - - -When you specify `jurisdiction: "eu"`: - -- All MCP session data stays within the EU -- User data processed by your tools remains in the EU -- State stored in the Durable Object stays in the EU - -Available jurisdictions include `"eu"` (European Union) and `"fedramp"` (FedRAMP compliant locations). Refer to [Durable Objects data location](/durable-objects/reference/data-location/) for more options. - -## Hibernation support - -`McpAgent` instances automatically support [WebSockets Hibernation](/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api), allowing stateful MCP servers to sleep during inactive periods while preserving their state. This means your agents only consume compute resources when actively processing requests, optimizing costs while maintaining the full context and conversation history. - -Hibernation is enabled by default and requires no additional configuration. - -## Stream resumability - -`McpAgent`'s Streamable HTTP transport survives the roughly 5-minute Cloudflare edge idle-stream watchdog so in-flight tool calls are not lost on a flaky connection: - -- **GET (standalone listen stream)** — when an `EventStore` is configured, idle drops are recovered by clients reconnecting with a `Last-Event-ID` header (no keepalive needed). Without an `EventStore`, a comment-frame keepalive (`: keepalive`, every 25 seconds) keeps long-lived listeners alive. -- **POST (tool response stream)** — always keepalive, so in-flight tool calls survive the idle watchdog. POST streams can additionally be resumed via `Last-Event-ID` when an `EventStore` is configured; a reconnecting client replays any events it missed up to and including the final response. Each POST stream's events are cleared when its close frame is written. - -`DurableObjectEventStore` is exported from `agents/mcp` for stateful `WorkerTransport` callers that embed the transport inside an Agent or Durable Object: - - - -```ts -import { DurableObjectEventStore } from "agents/mcp"; - -const eventStore = new DurableObjectEventStore(this.ctx.storage); -``` - - - -Refer to [MCP Transport](/agents/model-context-protocol/protocol/transport/) for transport configuration. - -## Authentication and authorization - -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/). - -When a user authenticates to your MCP server, their identity information and tokens are made available through the `props` parameter, 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 - -## State synchronization APIs - -The `McpAgent` class provides full access to the [Agent state APIs](/agents/runtime/lifecycle/state/): - -- [`state`](/agents/runtime/lifecycle/state/) — Current persisted state -- [`initialState`](/agents/runtime/lifecycle/state/#set-the-initial-state-for-an-agent) — Default state when instance starts -- [`setState`](/agents/runtime/lifecycle/state/) — Update and persist state -- [`onStateChanged`](/agents/runtime/lifecycle/state/#synchronizing-state) — React to state changes -- [`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. -::: - -For example, the following code implements an MCP server that remembers a counter value, and updates the counter when the `add` tool is called: - - - -```ts title="src/index.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: "Demo", - version: "1.0.0", - }); - - initialState: State = { - counter: 1, - }; - - async init() { - this.server.resource(`counter`, `mcp://resource/counter`, (uri) => { - return { - contents: [{ uri: uri.href, text: String(this.state.counter) }], - }; - }); - - this.server.tool( - "add", - "Add to the counter, stored in the MCP", - { a: z.number() }, - async ({ a }) => { - this.setState({ ...this.state, counter: this.state.counter + a }); - - return { - content: [ - { - type: "text", - text: String(`Added ${a}, total is now ${this.state.counter}`), - }, - ], - }; - }, - ); - } - - onStateChanged(state: State) { - console.log({ stateUpdate: state }); - } -} -``` - - - -## Elicitation on legacy servers - -[MCP elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) lets a server request user input while handling another request, such as a tool call. The legacy path defines two modes: - -- **Form mode** collects structured, non-sensitive data through the client. -- **URL mode** sends the user to an out-of-band interaction, such as third-party authorization or payment. - -The client must advertise support for a mode before the server sends it. - -### Form mode - -Call `this.server.server.elicitInput()` in a tool handler. Pass `extra.requestId` as `relatedRequestId` so the response returns on the stream for the originating tool call: - - - -```ts -const result = await this.server.server.elicitInput( - { - mode: "form", - message: "By how much do you want to increase the counter?", - requestedSchema: { - type: "object", - properties: { - amount: { - type: "number", - title: "Amount", - minimum: 1, - maximum: 100, - }, - }, - required: ["amount"], - }, - }, - { relatedRequestId: extra.requestId }, -); - -if (result.action !== "accept" || !result.content) { - return { content: [{ type: "text", text: "Counter unchanged." }] }; -} - -const amount = Number(result.content.amount); -``` - - - -For backwards compatibility, form requests may omit `mode: "form"`. The schema supports flat objects with primitive fields. Do not use form mode to request passwords, API keys, access tokens, payment credentials, or other secrets. - -### URL mode - -Use URL mode for interactions that must happen outside the MCP client. The request includes a message, the URL, and a unique `elicitationId`: - - - -```ts -const elicitationId = crypto.randomUUID(); -const result = await this.server.server.elicitInput( - { - mode: "url", - message: "Connect your account to continue.", - url: `https://example.com/connect?elicitationId=${elicitationId}`, - elicitationId, - }, - { relatedRequestId: extra.requestId }, -); - -if (result.action !== "accept") { - return { content: [{ type: "text", text: "Connection cancelled." }] }; -} - -return { - content: [ - { - type: "text", - text: "Connection page opened. Complete it in your browser.", - }, - ], -}; -``` - - - -For URL mode, `accept` means the user consented to open the URL. It does not mean the external interaction finished. The server may later send `notifications/elicitation/complete` with the same `elicitationId`. - -Do not put secrets, personal information, or a pre-authenticated protected-resource URL in `url`. Production servers should use HTTPS. Bind each request to the authenticated user, and verify that the same user completes the external flow. - -### Handle responses - -Both modes return one of three actions: - -| Action | Meaning | -| --------- | ----------------------------------------------------------------- | -| `accept` | The user submitted the form or consented to open the URL. | -| `decline` | The user explicitly rejected the request. | -| `cancel` | The user dismissed the request without making an explicit choice. | - -Accepted form responses include `content` that matches `requestedSchema`. URL responses omit `content`. Decline and cancel responses typically omit it. - - - -```ts -switch (result.action) { - case "accept": - // For form mode, validate and process result.content. - break; - case "decline": - return { content: [{ type: "text", text: "Request declined." }] }; - case "cancel": - return { content: [{ type: "text", text: "Request dismissed." }] }; -} -``` - - - -:::note[MCP client support] -Not all MCP clients implement legacy elicitation. Check the client before depending on it and provide a fallback when appropriate. Agents acting as MCP clients can handle both modes through [MCP client elicitation handlers](/agents/model-context-protocol/apis/client-api/#elicitation). -::: - -For more human-in-the-loop patterns, refer to [Human-in-the-loop patterns](/agents/concepts/agentic-patterns/human-in-the-loop/). - -## Next steps - - - - - - - - - - diff --git a/src/content/docs/agents/model-context-protocol/apis/client-api.mdx b/src/content/docs/agents/model-context-protocol/apis/client-api.mdx index 097dccef32a..ae3d8a02326 100644 --- a/src/content/docs/agents/model-context-protocol/apis/client-api.mdx +++ b/src/content/docs/agents/model-context-protocol/apis/client-api.mdx @@ -27,7 +27,7 @@ The MCP client capability lets your agent: :::note -This page covers connecting to MCP servers as a client. To create your own MCP server, refer to [Creating MCP servers](/agents/model-context-protocol/apis/agent-api/). +This page covers connecting to MCP servers as a client. To create your own MCP server, refer to [`createMcpHandler`](/agents/model-context-protocol/apis/handler-api/). ::: @@ -158,8 +158,6 @@ MCP server URLs are validated before connection to prevent Server-Side Request F Loopback addresses (`localhost`, `127.x.x.x`, `[::1]`) are **allowed** for local development. -For production connections to internal services, use the [RPC transport](/agents/model-context-protocol/protocol/transport/) with a Durable Object binding instead of HTTP. - ### Return value `addMcpServer()` returns the connection state: @@ -572,9 +570,9 @@ class MyAgent extends Agent { 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 @@ -178,7 +167,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 +178,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 @@ -274,75 +263,34 @@ export default { Notifications are isolate-local. A notification published in one Worker isolate does not reach a subscription stream running in another isolate. -## `createLegacyMcpHandler` - -`createLegacyMcpHandler` serves an SDK v1 server through `WorkerTransport`. - -```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; -``` +## Authentication context -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. +Wrap the handler with `OAuthProvider` from `@cloudflare/workers-oauth-provider` to require OAuth. The provider validates the access token before it calls the handler: - + -```ts -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { createLegacyMcpHandler } from "agents/mcp"; +```ts title="src/index.ts" +import OAuthProvider from "@cloudflare/workers-oauth-provider"; +import { createMcpHandler } from "agents/mcp/server"; -function createServer() { - return new McpServer({ name: "legacy-server", version: "1.0.0" }); -} +const mcpHandler = createMcpHandler(createServer); -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { - return createLegacyMcpHandler(createServer())(request, env, ctx); +export default new OAuthProvider({ + apiRoute: "/mcp", + apiHandler: { + fetch(request: Request, env: Env, ctx: ExecutionContext) { + return mcpHandler(request, env, ctx); + }, }, -} satisfies ExportedHandler; + authorizeEndpoint: "/authorize", + tokenEndpoint: "/token", + clientRegistrationEndpoint: "/register", + defaultHandler: AuthHandler, // your login and consent flow +}); ``` -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: @@ -383,11 +331,24 @@ server.registerTool( -Do not log or return `authInfo.token` or `authInfo.extra.props`. +`getMcpAuthContext()` also works inside the server factory, so you can register tools based on who is signed in: -## Migration +```ts +function createServer() { + const server = new McpServer({ name: "my-server", version: "1.0.0" }); + const props = getMcpAuthContext()?.props; -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. + if (props?.role === "admin") { + server.registerTool("deleteUser" /* ... */); + } + + return server; +} +``` + +When your own code authenticates the request instead of `OAuthProvider`, pass the identity with the `authContext` option, for example `createMcpHandler(createServer, { authContext: { props: { userId } } })`. + +Do not log or return `authInfo.token` or `authInfo.extra.props`. ## Related resources @@ -397,12 +358,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." /> - - { - 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}!` }], - })); - } -} -``` - -### 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" }); server.registerTool( "whoami", - { description: "Get the current user", inputSchema: {} }, + { description: "Get the current user" }, async (_args, context) => { - const auth = getMcpAuthContext(); - const name = (auth?.props.name as string) ?? "anonymous"; + const props = getMcpAuthContext()?.props as AuthContext | undefined; return { content: [ { type: "text", - text: `${name}: ${context.http?.authInfo?.clientId}`, + text: `${props?.claims.name ?? "anonymous"}: ${context.http?.authInfo?.clientId}`, }, ], }; @@ -232,57 +219,59 @@ function createServer() { return server; } - -export default { - fetch(request, env, ctx) { - return createMcpHandler(createServer)(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. +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. ```ts -export class MyMCP extends McpAgent { - server = new McpServer({ name: "Permissions Demo", version: "1.0.0" }); +function createServer() { + const server = new McpServer({ name: "Permissions Demo", version: "1.0.0" }); + const props = getMcpAuthContext()?.props as AuthContext | undefined; - async init() { - this.server.tool("publicTool", "Available to all users", {}, async () => ({ + server.registerTool( + "publicTool", + { description: "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" }, - ], - }; - } + }), + ); + + server.registerTool( + "adminAction", + { description: "Requires admin permission" }, + async () => { + if (!props?.permissions.includes("admin")) { return { - content: [{ type: "text", text: "Admin action completed" }], + 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 () => ({ + if (props?.permissions.includes("special_feature")) { + server.registerTool( + "specialTool", + { description: "Special feature" }, + async () => ({ content: [{ type: "text", text: "Special feature result" }], - })); - } + }), + ); } + + return server; } ``` -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. +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. ## 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..20579ee592e 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 @@ -172,39 +172,34 @@ export default { -## 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 { env } from "cloudflare:workers"; +import { getMcpAuthContext } from "agents/mcp/server"; -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}` }], - }; - }, - ); - } -} +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}` }], + }; + }, +); ``` +For data that needs transactions or per-user logic, call a Durable Object or an [Agent](/agents/) from the tool. + ## Next steps - - ```ts +const mcpHandler = createMcpHandler(createServer); + 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: - - - -```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", + apiHandler: { + fetch(request: Request, env: Env, ctx: ExecutionContext) { + 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" }, + // ... other OAuth configuration }); ``` -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/). ### 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/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..2f3cdae96fb 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,15 @@ 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. +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,36 +27,43 @@ 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 @@ -70,7 +77,7 @@ export class PaidMCP extends McpAgent { ## paidTool signature ```ts -this.server.paidTool( +server.paidTool( name, // Tool name description, // Tool description price, // Price in USD (e.g., 0.01) 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/) | From d3c830b1f95eb65b18ff5bb2c0f02b68fcd0ddbe Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:13:12 +0100 Subject: [PATCH 02/11] [Agents] Keep the McpAgent reference page Restore agent-api.mdx as the reference for existing McpAgent servers and move the McpAgent RPC transport and this.props auth examples there, so removing them from the transport and authorization pages loses nothing. Point the migration notes at it and drop the redirect. --- public/__redirects | 5 +- .../model-context-protocol/apis/agent-api.mdx | 642 ++++++++++++++++++ .../apis/handler-api.mdx | 2 +- .../guides/remote-mcp-server.mdx | 2 +- .../protocol/transport.mdx | 2 +- 5 files changed, 647 insertions(+), 6 deletions(-) create mode 100644 src/content/docs/agents/model-context-protocol/apis/agent-api.mdx diff --git a/public/__redirects b/public/__redirects index a040063617e..ea7b53a8412 100644 --- a/public/__redirects +++ b/public/__redirects @@ -258,8 +258,7 @@ /agents/api-reference/sdk/ /agents/runtime/agents-api/ 301 /agents/guides/build-mcp-server/ /agents/model-context-protocol/guides/remote-mcp-server/ 301 /agents/capabilities/mcp-server/ /agents/model-context-protocol/ 301 -/agents/model-context-protocol/mcp-agent-api/ /agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/ 301 -/agents/model-context-protocol/apis/agent-api/ /agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/ 301 +/agents/model-context-protocol/mcp-agent-api/ /agents/model-context-protocol/apis/agent-api/ 301 /agents/model-context-protocol/mcp-client-api/ /agents/model-context-protocol/apis/client-api/ 301 /agents/model-context-protocol/mcp-handler-api/ /agents/model-context-protocol/apis/handler-api/ 301 /agents/api-reference/calling-agents/ /agents/runtime/communication/routing/ 301 @@ -284,7 +283,7 @@ /agents/api-reference/email/ /agents/communication-channels/email/ 301 /agents/api-reference/get-current-agent/ /agents/runtime/lifecycle/get-current-agent/ 301 /agents/api-reference/http-sse/ /agents/runtime/communication/http-sse/ 301 -/agents/api-reference/mcp-agent-api/ /agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/ 301 +/agents/api-reference/mcp-agent-api/ /agents/model-context-protocol/apis/agent-api/ 301 /agents/api-reference/mcp-client-api/ /agents/model-context-protocol/apis/client-api/ 301 /agents/api-reference/mcp-handler-api/ /agents/model-context-protocol/apis/handler-api/ 301 /agents/api-reference/observability/ /agents/runtime/operations/observability/ 301 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 new file mode 100644 index 00000000000..e22409c8e04 --- /dev/null +++ b/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx @@ -0,0 +1,642 @@ +--- +pcx_content_type: reference +title: McpAgent +description: Reference the deprecated, feature-frozen McpAgent class while migrating existing stateful MCP servers to stateless handlers. +tags: + - MCP +sidebar: + order: 25 +products: + - agents +--- + +import { TypeScriptExample, LinkCard } from "~/components"; + +`McpAgent` creates a stateful legacy MCP server backed by a Durable Object. + +:::caution[Deprecated] + +`McpAgent` remains available only for existing legacy servers while they migrate. It is deprecated and feature-frozen. Migrate to [`createMcpHandler`](/agents/model-context-protocol/apis/handler-api/) at your earliest convenience. + +A server that depends on MCP session state, RPC, pushed server-to-client requests, standalone streams, or replay needs a staged migration. Design stateless equivalents, add a stateless route, and serve both lanes until clients migrate and existing sessions drain. + +Keep importing the legacy server `McpServer` from `@modelcontextprotocol/sdk`. An SDK v2 server from `@modelcontextprotocol/server` cannot run inside `McpAgent`. + +Refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/) for the feature mapping and dual-era rollout. + +::: + + + +```ts title="src/index.ts" +import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export class MyMCP extends McpAgent { + server = new McpServer({ name: "Demo", version: "1.0.0" }); + + async init() { + this.server.tool( + "add", + { a: z.number(), b: z.number() }, + async ({ a, b }) => ({ + content: [{ type: "text", text: String(a + b) }], + }), + ); + } +} +``` + + + +This means that each instance of your MCP server has its own durable state, backed by a [Durable Object](/durable-objects/), with its own [SQL database](/agents/runtime/lifecycle/state/). + +A stateless server can define [tools](/agents/model-context-protocol/protocol/tools/) with `@modelcontextprotocol/server` and serve them through `createMcpHandler`. + +But if you want your MCP server to: + +- remember previous tool calls, and responses it provided +- provide a game to the MCP client, remembering the state of the game board, previous moves, and the score +- cache the state of a previous external API call, so that subsequent tool calls can reuse it +- do anything that an Agent can do, but allow MCP clients to communicate with it + +You can use the APIs below in order to do so. + +## API overview + +| Property/Method | Description | +| ------------------------------- | -------------------------------------------------- | +| `state` | Current state object (persisted) | +| `initialState` | Default state when instance starts | +| `setState(state)` | Update and persist state | +| `onStateChanged(state)` | Called when state changes | +| `sql` | Execute SQL queries on embedded database | +| `server` | The `McpServer` instance for registering tools | +| `props` | User identity and tokens from OAuth authentication | +| `elicitInput(options, context)` | Request structured input from user | +| `McpAgent.serve(path, options)` | Static method to create a Worker handler | + +## Deploying with McpAgent.serve() + +The `McpAgent.serve()` static method creates a Worker handler that routes requests to your MCP server: + + + +```ts +import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export class MyMCP extends McpAgent { + server = new McpServer({ name: "my-server", version: "1.0.0" }); + + async init() { + this.server.tool("square", { n: z.number() }, async ({ n }) => ({ + content: [{ type: "text", text: String(n * n) }], + })); + } +} + +// Export the Worker handler +export default MyMCP.serve("/mcp"); +``` + + + +This is the simplest way to deploy an MCP server — about 15 lines of code. The `serve()` method handles Streamable HTTP transport automatically. + +### With OAuth authentication + +When using the [OAuth Provider Library](https://github.com/cloudflare/workers-oauth-provider), pass your MCP server to `apiHandlers`: + + + +```ts +import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; + +export default new OAuthProvider({ + apiHandlers: { "/mcp": MyMCP.serve("/mcp") }, + authorizeEndpoint: "/authorize", + tokenEndpoint: "/token", + clientRegistrationEndpoint: "/register", + defaultHandler: AuthHandler, +}); +``` + + + +## Data jurisdiction + +For GDPR and data residency compliance, specify a jurisdiction to ensure your MCP server instances run in specific regions: + + + +```ts +// EU jurisdiction for GDPR compliance +export default MyMCP.serve("/mcp", { jurisdiction: "eu" }); +``` + + + +With OAuth: + + + +```ts +export default new OAuthProvider({ + apiHandlers: { + "/mcp": MyMCP.serve("/mcp", { jurisdiction: "eu" }), + }, + // ... other OAuth config +}); +``` + + + +When you specify `jurisdiction: "eu"`: + +- All MCP session data stays within the EU +- User data processed by your tools remains in the EU +- State stored in the Durable Object stays in the EU + +Available jurisdictions include `"eu"` (European Union) and `"fedramp"` (FedRAMP compliant locations). Refer to [Durable Objects data location](/durable-objects/reference/data-location/) for more options. + +## Hibernation support + +`McpAgent` instances automatically support [WebSockets Hibernation](/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api), allowing stateful MCP servers to sleep during inactive periods while preserving their state. This means your agents only consume compute resources when actively processing requests, optimizing costs while maintaining the full context and conversation history. + +Hibernation is enabled by default and requires no additional configuration. + +## Stream resumability + +`McpAgent`'s Streamable HTTP transport survives the roughly 5-minute Cloudflare edge idle-stream watchdog so in-flight tool calls are not lost on a flaky connection: + +- **GET (standalone listen stream)** — when an `EventStore` is configured, idle drops are recovered by clients reconnecting with a `Last-Event-ID` header (no keepalive needed). Without an `EventStore`, a comment-frame keepalive (`: keepalive`, every 25 seconds) keeps long-lived listeners alive. +- **POST (tool response stream)** — always keepalive, so in-flight tool calls survive the idle watchdog. POST streams can additionally be resumed via `Last-Event-ID` when an `EventStore` is configured; a reconnecting client replays any events it missed up to and including the final response. Each POST stream's events are cleared when its close frame is written. + +`DurableObjectEventStore` is exported from `agents/mcp` for stateful `WorkerTransport` callers that embed the transport inside an Agent or Durable Object: + + + +```ts +import { DurableObjectEventStore } from "agents/mcp"; + +const eventStore = new DurableObjectEventStore(this.ctx.storage); +``` + + + +## 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 + +#### 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; +``` + + + +### 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" }], + }; + }, + ); + } +} +``` + + + +## 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/): + +- [`state`](/agents/runtime/lifecycle/state/) — Current persisted state +- [`initialState`](/agents/runtime/lifecycle/state/#set-the-initial-state-for-an-agent) — Default state when instance starts +- [`setState`](/agents/runtime/lifecycle/state/) — Update and persist state +- [`onStateChanged`](/agents/runtime/lifecycle/state/#synchronizing-state) — React to state changes +- [`sql`](/agents/runtime/agents-api/#sql-api) — Execute SQL queries on embedded database + +:::note[State resets after the session ends] +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: + + + +```ts title="src/index.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: "Demo", + version: "1.0.0", + }); + + initialState: State = { + counter: 1, + }; + + async init() { + this.server.resource(`counter`, `mcp://resource/counter`, (uri) => { + return { + contents: [{ uri: uri.href, text: String(this.state.counter) }], + }; + }); + + this.server.tool( + "add", + "Add to the counter, stored in the MCP", + { a: z.number() }, + async ({ a }) => { + this.setState({ ...this.state, counter: this.state.counter + a }); + + return { + content: [ + { + type: "text", + text: String(`Added ${a}, total is now ${this.state.counter}`), + }, + ], + }; + }, + ); + } + + onStateChanged(state: State) { + console.log({ stateUpdate: state }); + } +} +``` + + + +## Elicitation on legacy servers + +[MCP elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) lets a server request user input while handling another request, such as a tool call. The legacy path defines two modes: + +- **Form mode** collects structured, non-sensitive data through the client. +- **URL mode** sends the user to an out-of-band interaction, such as third-party authorization or payment. + +The client must advertise support for a mode before the server sends it. + +### Form mode + +Call `this.server.server.elicitInput()` in a tool handler. Pass `extra.requestId` as `relatedRequestId` so the response returns on the stream for the originating tool call: + + + +```ts +const result = await this.server.server.elicitInput( + { + mode: "form", + message: "By how much do you want to increase the counter?", + requestedSchema: { + type: "object", + properties: { + amount: { + type: "number", + title: "Amount", + minimum: 1, + maximum: 100, + }, + }, + required: ["amount"], + }, + }, + { relatedRequestId: extra.requestId }, +); + +if (result.action !== "accept" || !result.content) { + return { content: [{ type: "text", text: "Counter unchanged." }] }; +} + +const amount = Number(result.content.amount); +``` + + + +For backwards compatibility, form requests may omit `mode: "form"`. The schema supports flat objects with primitive fields. Do not use form mode to request passwords, API keys, access tokens, payment credentials, or other secrets. + +### URL mode + +Use URL mode for interactions that must happen outside the MCP client. The request includes a message, the URL, and a unique `elicitationId`: + + + +```ts +const elicitationId = crypto.randomUUID(); +const result = await this.server.server.elicitInput( + { + mode: "url", + message: "Connect your account to continue.", + url: `https://example.com/connect?elicitationId=${elicitationId}`, + elicitationId, + }, + { relatedRequestId: extra.requestId }, +); + +if (result.action !== "accept") { + return { content: [{ type: "text", text: "Connection cancelled." }] }; +} + +return { + content: [ + { + type: "text", + text: "Connection page opened. Complete it in your browser.", + }, + ], +}; +``` + + + +For URL mode, `accept` means the user consented to open the URL. It does not mean the external interaction finished. The server may later send `notifications/elicitation/complete` with the same `elicitationId`. + +Do not put secrets, personal information, or a pre-authenticated protected-resource URL in `url`. Production servers should use HTTPS. Bind each request to the authenticated user, and verify that the same user completes the external flow. + +### Handle responses + +Both modes return one of three actions: + +| Action | Meaning | +| --------- | ----------------------------------------------------------------- | +| `accept` | The user submitted the form or consented to open the URL. | +| `decline` | The user explicitly rejected the request. | +| `cancel` | The user dismissed the request without making an explicit choice. | + +Accepted form responses include `content` that matches `requestedSchema`. URL responses omit `content`. Decline and cancel responses typically omit it. + + + +```ts +switch (result.action) { + case "accept": + // For form mode, validate and process result.content. + break; + case "decline": + return { content: [{ type: "text", text: "Request declined." }] }; + case "cancel": + return { content: [{ type: "text", text: "Request dismissed." }] }; +} +``` + + + +:::note[MCP client support] +Not all MCP clients implement legacy elicitation. Check the client before depending on it and provide a fallback when appropriate. Agents acting as MCP clients can handle both modes through [MCP client elicitation handlers](/agents/model-context-protocol/apis/client-api/#elicitation). +::: + +For more human-in-the-loop patterns, refer to [Human-in-the-loop patterns](/agents/concepts/agentic-patterns/human-in-the-loop/). + +## Next steps + + + + diff --git a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx index 488dce4fe27..89656a918da 100644 --- a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx +++ b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx @@ -14,7 +14,7 @@ import { LinkCard, PackageManagers, TypeScriptExample } from "~/components"; `createMcpHandler` from `agents/mcp/server` serves an MCP SDK v2 server from a Worker. The handler is stateless: it builds a fresh server for every request, so you do not need a Durable Object, protocol session, or WebSocket. -To move an existing `McpAgent` server, refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/). +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/). ## Install dependencies 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 de3b1c2447f..3cd3f2f0c64 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 @@ -21,7 +21,7 @@ This guide shows how to deploy a remote MCP server on Cloudflare using [Streamab 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. -To move an existing `McpAgent` server, refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/). +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 diff --git a/src/content/docs/agents/model-context-protocol/protocol/transport.mdx b/src/content/docs/agents/model-context-protocol/protocol/transport.mdx index 4cb01ef74db..5a0f85efe90 100644 --- a/src/content/docs/agents/model-context-protocol/protocol/transport.mdx +++ b/src/content/docs/agents/model-context-protocol/protocol/transport.mdx @@ -101,7 +101,7 @@ export default new OAuthProvider({ 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. -To move an existing `McpAgent` server, refer to [Migrate to MCP SDK v2](/agents/model-context-protocol/guides/migrate-to-mcp-sdk-v2/). +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 From 19b0fec1e28a72c524c1c987f1246f9fb7d2f2a3 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:30:10 +0100 Subject: [PATCH 03/11] [Agents] Document MCP auth with the Workers OAuth Provider 1.x split API Authorization now builds an OAuthAuthorizationServer and makes the MCP server an OAuthResourceServer around createMcpHandler, validated over a Service Binding. Securing MCP servers uses the library's consent and upstream sign-in helpers instead of hand-rolled CSRF and state code. Drop the context.http.authInfo claims (no provider release sets it) and fix the 1.x changelog's migration links. --- .../2026-10-01-workers-oauth-provider-1x.mdx | 4 +- .../model-context-protocol/apis/agent-api.mdx | 4 + .../apis/handler-api.mdx | 37 +- .../guides/remote-mcp-server.mdx | 168 +-------- .../guides/securing-mcp-server.mdx | 309 +++++------------ .../protocol/authorization.mdx | 320 +++++++++++------- .../protocol/transport.mdx | 19 +- 7 files changed, 334 insertions(+), 527 deletions(-) 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/agents/model-context-protocol/apis/agent-api.mdx b/src/content/docs/agents/model-context-protocol/apis/agent-api.mdx index e22409c8e04..9fb5c59261b 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 @@ -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", + }, }); ``` diff --git a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx index 89656a918da..9fc0d9513e3 100644 --- a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx +++ b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx @@ -265,35 +265,34 @@ Notifications are isolate-local. A notification published in one Worker isolate ## Authentication context -Wrap the handler with `OAuthProvider` from `@cloudflare/workers-oauth-provider` to require OAuth. The provider validates the access token before it calls the handler: +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 title="src/index.ts" -import OAuthProvider from "@cloudflare/workers-oauth-provider"; +import { OAuthResourceServer } from "@cloudflare/workers-oauth-provider"; import { createMcpHandler } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; const mcpHandler = createMcpHandler(createServer); -export default new OAuthProvider({ - apiRoute: "/mcp", - apiHandler: { - fetch(request: Request, env: Env, ctx: ExecutionContext) { +export default new OAuthResourceServer({ + resourceMetadata: { + resource: env.MCP_SERVER_URL, // the URL MCP clients connect to + authorization_servers: [env.AUTH_SERVER_URL], + }, + validateToken: (env) => env.AUTH_SERVER.validateToken, // Service Binding to your authorization server + handler: { + fetch(request, env, ctx) { return mcpHandler(request, env, ctx); }, }, - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", - defaultHandler: AuthHandler, // your login and consent flow }); ``` -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`. Read them with `getMcpAuthContext()`: ```ts interface McpAuthContext { @@ -310,18 +309,14 @@ import { z } from "zod"; server.registerTool( "whoami", { description: "Return the current identity", inputSchema: z.object({}) }, - async (_args, context) => { + async () => { const auth = getMcpAuthContext(); return { content: [ { type: "text", - text: JSON.stringify({ - clientId: context.http?.authInfo?.clientId, - scopes: context.http?.authInfo?.scopes, - userId: auth?.props.userId, - }), + text: JSON.stringify({ userId: auth?.props.userId }), }, ], }; @@ -346,9 +341,9 @@ function createServer() { } ``` -When your own code authenticates the request instead of `OAuthProvider`, pass the identity with the `authContext` option, for example `createMcpHandler(createServer, { authContext: { props: { userId } } })`. +When your own code authenticates the request instead of `OAuthResourceServer`, pass the identity with the `authContext` option, for example `createMcpHandler(createServer, { authContext: { props: { userId } } })`. -Do not log or return `authInfo.token` or `authInfo.extra.props`. +Props often hold upstream access tokens. Do not log or return them. ## Related resources 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 3cd3f2f0c64..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 @@ -144,170 +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 +## 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. +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): -### Cloudflare Access OAuth +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. -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). +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()`. -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 MCP server is wrapped in an `OAuthProvider` whose `defaultHandler` is the `GitHubHandler`: - -```ts ins="defaultHandler: GitHubHandler," -import { createMcpHandler } from "agents/mcp/server"; -import { GitHubHandler } from "./github-handler"; - -const mcpHandler = createMcpHandler(createServer); - -export default new OAuthProvider({ - apiRoute: "/mcp", - apiHandler: { - fetch(request: Request, env: Env, ctx: ExecutionContext) { - return mcpHandler(request, env, ctx); - }, - }, - defaultHandler: GitHubHandler, - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", -}); -``` - -This ensures that your users are redirected to GitHub to authenticate. After they sign in, `createServer` reads the GitHub user from `getMcpAuthContext()` and registers tools for that user. 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: - - ```json - { - "kvNamespaces": [ - { - "binding": "OAUTH_KV", - "id": "" - } - ] - } - ``` - -4. Deploy the MCP server to your Cloudflare `workers.dev` domain: - - ```bash - npm run deploy - ``` - -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 @@ -320,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 751373d388b..4e16a4066a3 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 @@ -12,272 +12,137 @@ products: import { TypeScriptExample, LinkCard } 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 { createMcpHandler } from "agents/mcp/server"; -import { createServer } from "./mcp"; - -const mcpHandler = createMcpHandler(createServer); - -export default new OAuthProvider({ - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", - apiRoute: "/mcp", - apiHandler: { - fetch(request: Request, env: Env, ctx: ExecutionContext) { - return mcpHandler(request, env, ctx); - }, - }, - 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. - -### CSRF protection - -Without CSRF protection, attackers can trick users into approving malicious OAuth clients. Use a random token stored in a secure cookie: +The library's consent helpers do this. `describeConsent()` returns what the page must show, `beginConsent()` returns headers that bind the form to the browser and block framing, and `approveConsent()` or `denyConsent()` finish it: ```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 }; -} +const oauth = authorizationServer.getOAuthApi(env); + +// GET /authorize, after signing the user in +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(consentPage(details, consent.handle), { + headers: consent.headers, +}); -// 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"); - } - - // Clear cookie after use (one-time use) - return { - clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`, - }; +// POST /authorize +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, { + scope: form.getAll("scope").map(String), +}); +const { redirectTo } = await oauth.completeAuthorization({ + request: approved.request, // from storage, not from the form + userId: session.userId, + metadata: {}, + scope: approved.request.scope, + props: { userId: session.userId }, +}); +approved.headers.set("Location", redirectTo); +return new Response(null, { status: 302, headers: approved.headers }); ``` -Include the token as a hidden field in your consent form: - -```html - -``` - -### Input sanitization +The form carries only a single-use handle. The authorization request stays on the server, so a forged form cannot approve a different client or redirect URI. -User-controlled content (client names, logos, URIs) can execute malicious scripts if not sanitized: - - +Escape everything that comes from the client before you render it. Client names, URIs, and scope strings come from registration or a client metadata document, so an attacker chooses them: ```ts -function sanitizeText(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -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 ""; - } - return url; - } catch { - return ""; - } -} - -// Always sanitize before rendering -const clientName = sanitizeText(client.clientName); -const logoUrl = sanitizeText(sanitizeUrl(client.logoUri)); +const escape = (value: string) => + value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); ``` - +To skip the page for clients a user already approved, pass `remember` to `approveConsent()` and check `isConsentRemembered()` before you show it. Refer to [Building a consent page](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/consent-page.md) for a complete page, remembered consent, and which errors to redirect back to the client and which to render. -### Content Security Policy +## Signing in through a third-party provider -CSP headers instruct browsers to block dangerous content: +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. - - -```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, - }; -} -``` - - - -## State handling - -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: +Ask for consent first, then use `beginUpstream()` to send the user to the provider and `finishUpstream()` in your callback. `beginUpstream()` creates a `state` value bound to the user's browser, so a stolen provider code cannot be replayed elsewhere: ```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 }; -} - -// Bind state to browser session with a hashed cookie -async function bindStateToSession(stateToken: string) { - const encoder = new TextEncoder(); - const hashBuffer = await crypto.subtle.digest( - "SHA-256", - encoder.encode(stateToken), - ); - 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`, - }; -} - -// 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"); - } - - // Check state exists in KV - const storedData = await kv.get(`oauth:state:${stateFromQuery}`); - if (!storedData) { - throw new Error("Invalid or expired state"); - } - - // Validate state matches session cookie - // ... (hash comparison logic) - - await kv.delete(`oauth:state:${stateFromQuery}`); - return JSON.parse(storedData); -} +// POST /authorize: the user approved this client +const approved = await oauth.approveConsent(request, handle); +const { state, headers } = await oauth.beginUpstream(approved.request, { + data: { verifier }, // returned at the callback, never sent to the provider + headers: approved.headers, +}); +headers.set("Location", providerAuthorizeUrl({ state })); +return new Response(null, { status: 302, headers }); + +// GET /callback +const { request: original, data, headers: clear } = + await oauth.finishUpstream<{ verifier: string }>(request); +const upstream = await exchangeProviderCode( + new URL(request.url).searchParams.get("code")!, + data.verifier, +); +const { redirectTo } = await oauth.completeAuthorization({ + request: original, + userId: upstream.userId, + metadata: {}, + scope: original.scope, + props: { providerToken: upstream.accessToken }, // encrypted at rest +}); +clear.set("Location", redirectTo); +return new Response(null, { status: 302, headers: clear }); ``` -## Cookie security - -### Why use the `__Host-` prefix? - -The `__Host-` prefix prevents subdomain attacks, which is especially important on `*.workers.dev` domains: - -- Must be set with `Secure` flag (HTTPS only) -- Must have `Path=/` -- Must not have a `Domain` attribute +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. -Without `__Host-`, an attacker controlling `evil.workers.dev` could set cookies for your `mcp-server.workers.dev` domain. +## Checks on the MCP server -### Multiple OAuth flows +`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. -If running multiple OAuth flows on the same domain, namespace your cookies: - -```txt -__Host-CSRF_TOKEN_GITHUB -__Host-CSRF_TOKEN_GOOGLE -__Host-APPROVED_CLIENTS_GITHUB -__Host-APPROVED_CLIENTS_GOOGLE -``` - -## Approved clients registry - -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`; -} +handler: { + fetch(request, env, ctx) { + 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 | +| 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/protocol/authorization.mdx b/src/content/docs/agents/model-context-protocol/protocol/authorization.mdx index 351424e8188..d39e4f2fefa 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,95 +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 +``` -The following sections describe each of these options and link to runnable code examples for each. +One authorization server can issue tokens for several MCP servers, and each can run in its own Worker. -## Authorization options +## Build the authorization server -### (1) Cloudflare Access OAuth provider +Create a Worker for the authorization server and install the library: -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/). + -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/). +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. -### (2) Third-party OAuth Provider + + +```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", +}); -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). +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 }); + } -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. + // 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); +} -```ts ins="defaultHandler: MyAuthHandler," -import { createMcpHandler } from "agents/mcp/server"; -import MyAuthHandler from "./auth-handler"; +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 + +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. + + + +```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; name: string }; + +interface Env { + AUTH_SERVER: AuthorizationServerBinding; + AUTH_SERVER_URL: string; + MCP_SERVER_URL: string; +} + +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: `Hello, ${props.name}!` }], + }), + ); + + return server; +} const mcpHandler = createMcpHandler(createServer); -export default new OAuthProvider({ - apiRoute: "/mcp", - // Your MCP server. OAuthProvider needs an object with a fetch method: - apiHandler: { - fetch(request: Request, env: Env, ctx: ExecutionContext) { +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); }, }, - // Replace this handler with your own handler for authentication and authorization with the third-party provider: - defaultHandler: MyAuthHandler, - authorizeEndpoint: "/authorize", - tokenEndpoint: "/token", - clientRegistrationEndpoint: "/register", }); ``` -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. -### (3) Bring your own OAuth Provider +## Sign users in -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). +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: -You can use the auth provider to: +- **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). -- 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. +### Cloudflare Access + +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/). + +### Identity providers + +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 @@ -134,60 +274,11 @@ 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 - -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: { - fetch(request: Request, env: Env, ctx: ExecutionContext) { - return mcpHandler(request, env, ctx); - }, - }, - // 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. - -The authorization flow in this case works like this: - -```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 -``` - -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. +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). ## Using authentication context in tools -When a user authenticates through the OAuth Provider, the `props` you passed to `completeAuthorization()` are available inside your server through `getMcpAuthContext()`. A compatible Workers OAuth Provider also supplies standard token metadata at `context.http.authInfo`. +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. ```ts import { getMcpAuthContext } from "agents/mcp/server"; @@ -204,14 +295,11 @@ function createServer() { server.registerTool( "whoami", { description: "Get the current user" }, - async (_args, context) => { + async () => { const props = getMcpAuthContext()?.props as AuthContext | undefined; return { content: [ - { - type: "text", - text: `${props?.claims.name ?? "anonymous"}: ${context.http?.authInfo?.clientId}`, - }, + { type: "text", text: `Hello, ${props?.claims.name ?? "anonymous"}!` }, ], }; }, @@ -221,7 +309,7 @@ function createServer() { } ``` -Do not log or return the raw access token. +Props often hold upstream access tokens. Do not log or return them. ## Permission-based tool access @@ -273,6 +361,8 @@ function createServer() { 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. +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 ```ts +import { OAuthResourceServer } from "@cloudflare/workers-oauth-provider"; +import { createMcpHandler } from "agents/mcp/server"; +import { env } from "cloudflare:workers"; + const mcpHandler = createMcpHandler(createServer); -export default new OAuthProvider({ - apiRoute: "/mcp", - apiHandler: { - fetch(request: Request, env: Env, ctx: ExecutionContext) { +export default new OAuthResourceServer({ + resourceMetadata: { + resource: env.MCP_SERVER_URL, // the URL MCP clients connect to + authorization_servers: [env.AUTH_SERVER_URL], + }, + validateToken: (env) => env.AUTH_SERVER.validateToken, // Service Binding to your authorization server + handler: { + fetch(request, env, ctx) { return mcpHandler(request, env, ctx); }, }, - // ... other OAuth configuration }); ``` From 4b72f1db9522552ff09894c06f16a0e4fad9ed4d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:41:18 +0100 Subject: [PATCH 04/11] [Agents] Rename MCP API pages: MCP Server, MCP Client, McpAgent (deprecated) --- .../docs/agents/model-context-protocol/apis/agent-api.mdx | 4 ++-- .../docs/agents/model-context-protocol/apis/client-api.mdx | 2 +- .../docs/agents/model-context-protocol/apis/handler-api.mdx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 9fb5c59261b..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 --- diff --git a/src/content/docs/agents/model-context-protocol/apis/client-api.mdx b/src/content/docs/agents/model-context-protocol/apis/client-api.mdx index ae3d8a02326..8884de6a9fc 100644 --- a/src/content/docs/agents/model-context-protocol/apis/client-api.mdx +++ b/src/content/docs/agents/model-context-protocol/apis/client-api.mdx @@ -1,5 +1,5 @@ --- -title: McpClient +title: MCP Client description: Connect Agents to external MCP servers to use their tools, resources, and prompts over the Model Context Protocol. pcx_content_type: reference tags: diff --git a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx index 9fc0d9513e3..f89f99f3a13 100644 --- a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx +++ b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx @@ -1,6 +1,6 @@ --- pcx_content_type: reference -title: MCP handler API +title: MCP Server description: Create stateless MCP servers on Cloudflare Workers with createMcpHandler from the Agents SDK. tags: - MCP From 02fd30c91aaebd23a1797f731e709c4996d68368 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:41:31 +0100 Subject: [PATCH 05/11] [Agents] Link to the MCP Client page by its new name --- src/content/docs/agents/tools/codemode/mcp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/docs/agents/tools/codemode/mcp.mdx b/src/content/docs/agents/tools/codemode/mcp.mdx index 6b96ac871d3..a4c9bc9bd40 100644 --- a/src/content/docs/agents/tools/codemode/mcp.mdx +++ b/src/content/docs/agents/tools/codemode/mcp.mdx @@ -20,7 +20,7 @@ This page covers an Agent consuming an MCP server. To publish Code Mode as an MC 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/). From 411557c1a651b3158796b8869aa705aa297260a5 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:43:10 +0100 Subject: [PATCH 06/11] [Agents] Explain the Workers OAuth Provider consent and sign-in APIs Replace the hand-written escape helper with an API table for describeConsent, beginConsent, approveConsent, denyConsent and isConsentRemembered, and add one for beginUpstream, finishUpstream and tokenExchangeCallback. The library returns consent values but does not render HTML, so the page still escapes them. --- .../guides/securing-mcp-server.mdx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) 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 4e16a4066a3..dc1712f38eb 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 @@ -20,7 +20,15 @@ This guide covers what to get right when you protect an MCP server with [Workers 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. -The library's consent helpers do this. `describeConsent()` returns what the page must show, `beginConsent()` returns headers that bind the form to the browser and block framing, and `approveConsent()` or `denyConsent()` finish it: +Workers OAuth Provider handles the security of the consent flow. Your code renders the page. + +| 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. | @@ -59,22 +67,23 @@ return new Response(null, { status: 302, headers: approved.headers }); -The form carries only a single-use handle. The authorization request stays on the server, so a forged form cannot approve a different client or redirect URI. +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. -Escape everything that comes from the client before you render it. Client names, URIs, and scope strings come from registration or a client metadata document, so an attacker chooses them: +`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. -```ts -const escape = (value: string) => - value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); -``` - -To skip the page for clients a user already approved, pass `remember` to `approveConsent()` and check `isConsentRemembered()` before you show it. Refer to [Building a consent page](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/consent-page.md) for a complete page, remembered consent, and which errors to redirect back to the client and which to render. +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. ## 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. -Ask for consent first, then use `beginUpstream()` to send the user to the provider and `finishUpstream()` in your callback. `beginUpstream()` creates a `state` value bound to the user's browser, so a stolen provider code cannot be replayed elsewhere: +Ask for consent first, then hand the user to the provider: + +| 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. | @@ -139,7 +148,7 @@ Props can hold upstream access tokens. Do not log them or return them from tools | --------------------------------------------------- | ---------------------------------------------- | | 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 | +| 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()` | From 707d572d4001bd0e26959b56bed777d0aad48f0c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:43:42 +0100 Subject: [PATCH 07/11] [Agents] Call MCP hosts agents in the MCP terminology --- src/content/docs/agents/model-context-protocol/index.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/content/docs/agents/model-context-protocol/index.mdx b/src/content/docs/agents/model-context-protocol/index.mdx index a50df326d3e..5230ab269c7 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 From 80215161a57f999430bf7bc26972ff1ef46373b0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:46:14 +0100 Subject: [PATCH 08/11] [Agents] Update MCP server best practices Replace the April 2025 list with current guidance: keep tool definitions out of the context window with Code Mode on the client or server, use granular authorization with step-up, and declare accurate input and output schemas so strict clients accept your tools. --- src/content/docs/agents/model-context-protocol/index.mdx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/content/docs/agents/model-context-protocol/index.mdx b/src/content/docs/agents/model-context-protocol/index.mdx index 5230ab269c7..07710e07df6 100644 --- a/src/content/docs/agents/model-context-protocol/index.mdx +++ b/src/content/docs/agents/model-context-protocol/index.mdx @@ -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/), [Code Mode MCP server patterns](/agents/model-context-protocol/codemode/), and the guides for [building a Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/) and [serving an OpenAPI spec through Code Mode](/agents/model-context-protocol/guides/build-codemode-openapi-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 From 9c3a4526b819917b941b91e6329c990f729ca2ce Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 17:52:27 +0100 Subject: [PATCH 09/11] [Agents] Merge the Code Mode MCP pages into one guide Fold the Code Mode MCP server patterns page and the search-and-execute guide into Build a Code Mode MCP server, which now covers choosing a pattern, wrapping an existing server, and publishing a large API with search and execute. Redirect the removed pages and update links. --- public/__redirects | 2 + .../2026-03-26-mcp-portal-code-mode.mdx | 2 +- src/content/docs/agents/concepts/tools.mdx | 2 +- .../servers-for-cloudflare/index.mdx | 2 +- .../model-context-protocol/codemode.mdx | 117 --------- .../guides/build-codemode-mcp-server.mdx | 245 ++++++++++++++++-- .../build-codemode-openapi-mcp-server.mdx | 216 --------------- .../agents/model-context-protocol/index.mdx | 2 +- .../docs/agents/tools/codemode/mcp.mdx | 2 +- .../docs/agents/tools/codemode/openapi.mdx | 2 +- 10 files changed, 238 insertions(+), 354 deletions(-) delete mode 100644 src/content/docs/agents/model-context-protocol/codemode.mdx delete mode 100644 src/content/docs/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server.mdx 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/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/cloudflare/servers-for-cloudflare/index.mdx b/src/content/docs/agents/model-context-protocol/cloudflare/servers-for-cloudflare/index.mdx index 035053feb43..3064aa34ecc 100644 --- a/src/content/docs/agents/model-context-protocol/cloudflare/servers-for-cloudflare/index.mdx +++ b/src/content/docs/agents/model-context-protocol/cloudflare/servers-for-cloudflare/index.mdx @@ -22,7 +22,7 @@ The [Cloudflare API MCP server](https://github.com/cloudflare/mcp) at `https://m The server provides the `search()` and `execute()` tools to find and call API endpoints, and the `docs()` tool to search Cloudflare developer documentation. -It uses [the search-and-execute Code Mode pattern](/agents/model-context-protocol/codemode/#search-and-execute), a technique where the model writes JavaScript against a typed representation of the OpenAPI spec and the Cloudflare API client, rather than loading individual tool definitions for each endpoint. The generated code runs inside an isolated [Dynamic Worker](/dynamic-workers/) sandbox. +It uses [the search-and-execute Code Mode pattern](/agents/model-context-protocol/guides/build-codemode-mcp-server/#publish-a-large-api-with-search-and-execute), a technique where the model writes JavaScript against a typed representation of the OpenAPI spec and the Cloudflare API client, rather than loading individual tool definitions for each endpoint. The generated code runs inside an isolated [Dynamic Worker](/dynamic-workers/) sandbox. This approach uses approximately 1,000 tokens regardless of how many API endpoints exist. An equivalent MCP server that exposed every endpoint as a native tool would consume over 1 million tokens — more than the entire context window of most foundation models. diff --git a/src/content/docs/agents/model-context-protocol/codemode.mdx b/src/content/docs/agents/model-context-protocol/codemode.mdx deleted file mode 100644 index 294156a5f43..00000000000 --- a/src/content/docs/agents/model-context-protocol/codemode.mdx +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: Code Mode MCP server patterns -description: Understand single-code-tool and search-and-execute patterns for exposing tools and large APIs through MCP. -pcx_content_type: concept -sidebar: - order: 5 -tags: - - AI - - MCP -products: - - agents ---- - -import { CardGrid, LinkCard } from "~/components"; - -A Code Mode MCP server lets any Model Context Protocol (MCP) client use model-written code without providing its own sandbox. The MCP server exposes code execution as its tool interface and runs generated JavaScript in an isolated Worker. - -Code Mode MCP servers follow two patterns: - -| 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. | - -Both patterns let generated code compose operations and keep intermediate results outside the model context. They differ in how the model discovers available operations. - -## Single code tool - -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"); -}; -``` - -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/index.mdx b/src/content/docs/agents/model-context-protocol/index.mdx index 07710e07df6..9ac8a988e07 100644 --- a/src/content/docs/agents/model-context-protocol/index.mdx +++ b/src/content/docs/agents/model-context-protocol/index.mdx @@ -31,7 +31,7 @@ The MCP standard supports two modes of operation: ### Best practices -- **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/), [Code Mode MCP server patterns](/agents/model-context-protocol/codemode/), and the guides for [building a Code Mode MCP server](/agents/model-context-protocol/guides/build-codemode-mcp-server/) and [serving an OpenAPI spec through Code Mode](/agents/model-context-protocol/guides/build-codemode-openapi-mcp-server/). +- **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/). diff --git a/src/content/docs/agents/tools/codemode/mcp.mdx b/src/content/docs/agents/tools/codemode/mcp.mdx index a4c9bc9bd40..a12ef48cca2 100644 --- a/src/content/docs/agents/tools/codemode/mcp.mdx +++ b/src/content/docs/agents/tools/codemode/mcp.mdx @@ -13,7 +13,7 @@ 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 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 From 0555b920192a4f0b43ec2ba33b3d69c5fd43033e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 22 Oct 2026 18:05:06 +0100 Subject: [PATCH 10/11] [Agents] Fix the securing guide build: plain code blocks for fragments TypeScriptExample transpiles each block, so object-literal and route-handler fragments failed the build. Use plain ts blocks for them. --- .../guides/securing-mcp-server.mdx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) 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 dc1712f38eb..027ef58a110 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,7 +10,7 @@ products: - agents --- -import { TypeScriptExample, LinkCard } from "~/components"; +import { LinkCard } 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 authorization between MCP clients and servers. @@ -30,8 +30,6 @@ Workers OAuth Provider handles the security of the consent flow. Your code rende | `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 const oauth = authorizationServer.getOAuthApi(env); @@ -65,8 +63,6 @@ approved.headers.set("Location", redirectTo); return new Response(null, { status: 302, headers: approved.headers }); ``` - - 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. `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. @@ -85,8 +81,6 @@ Ask for consent first, then hand the user to the provider: | `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 // POST /authorize: the user approved this client const approved = await oauth.approveConsent(request, handle); @@ -115,8 +109,6 @@ clear.set("Location", redirectTo); return new Response(null, { status: 302, headers: clear }); ``` - - 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. ## Checks on the MCP server @@ -125,8 +117,6 @@ Refer to [Signing in through another provider](https://github.com/cloudflare/wor `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 handler: { fetch(request, env, ctx) { @@ -138,8 +128,6 @@ handler: { }, ``` - - Props can hold upstream access tokens. Do not log them or return them from tools. ## Security checklist From 500ab8c5958a70e0174259c643baa3db431f4916 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 23 Oct 2026 09:10:22 +0100 Subject: [PATCH 11/11] [Agents] Make every MCP code example a complete, runnable Worker Replace fragments in the handler API, tools, transport, authorization, and securing pages with full modules wrapped in TypeScriptExample. The securing guide now has a complete authorization server with a consent page, one that signs users in with GitHub, and a resource server that checks scopes. Tool examples declare output schemas and return structuredContent. The x402 paidTool signature becomes a parameter table. Every example type-checks against agents 0.29, @modelcontextprotocol/server 2.3.1, and workers-oauth-provider 1.2.4, and was run under wrangler dev. --- .../apis/handler-api.mdx | 216 ++++++++--- .../guides/securing-mcp-server.mdx | 349 +++++++++++++++--- .../protocol/authorization.mdx | 81 ++-- .../model-context-protocol/protocol/tools.mdx | 198 +++++++--- .../protocol/transport.mdx | 39 +- .../payments/x402/charge-for-mcp-tools.mdx | 28 +- 6 files changed, 696 insertions(+), 215 deletions(-) diff --git a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx index f89f99f3a13..77dc53d808b 100644 --- a/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx +++ b/src/content/docs/agents/model-context-protocol/apis/handler-api.mdx @@ -143,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; ``` @@ -211,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 @@ -249,18 +300,40 @@ 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. ## Authentication context @@ -270,20 +343,51 @@ To require OAuth, wrap the handler in an `OAuthResourceServer` from `@cloudflare ```ts title="src/index.ts" -import { OAuthResourceServer } from "@cloudflare/workers-oauth-provider"; -import { createMcpHandler } from "agents/mcp/server"; +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, // the URL MCP clients connect to + resource: env.MCP_SERVER_URL, // tokens for any other resource are rejected authorization_servers: [env.AUTH_SERVER_URL], }, - validateToken: (env) => env.AUTH_SERVER.validateToken, // Service Binding to your authorization server + 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); }, }, @@ -292,7 +396,7 @@ export default new OAuthResourceServer({ -The resource server passes the `props` stored with `completeAuthorization()` as `ctx.props`. Read them with `getMcpAuthContext()`: +The resource server passes the `props` stored with `completeAuthorization()` as `ctx.props`, and `getMcpAuthContext()` returns them inside your server: ```ts interface McpAuthContext { @@ -300,48 +404,66 @@ 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 () => { - const auth = getMcpAuthContext(); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ userId: auth?.props.userId }), - }, - ], - }; - }, -); -``` - - - -`getMcpAuthContext()` also works inside the server factory, so you can register tools based on who is signed in: +type Props = { userId: string; role: "admin" | "member" }; -```ts function createServer() { const server = new McpServer({ name: "my-server", version: "1.0.0" }); - const props = getMcpAuthContext()?.props; + 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" /* ... */); + 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; ``` -When your own code authenticates the request instead of `OAuthResourceServer`, pass the identity with the `authContext` option, for example `createMcpHandler(createServer, { authContext: { props: { userId } } })`. + Props often hold upstream access tokens. Do not log or return them. 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 027ef58a110..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,7 +10,7 @@ products: - agents --- -import { 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 authorization between MCP clients and servers. @@ -30,39 +30,114 @@ Workers OAuth Provider handles the security of the consent flow. Your code rende | `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 -const oauth = authorizationServer.getOAuthApi(env); - -// GET /authorize, after signing the user in -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(consentPage(details, consent.handle), { - headers: consent.headers, +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, }); -// POST /authorize -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 }); +// 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" }; +} + +// 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``, + )} + + +
`; +} + +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 }); + } + + 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 }); +} + +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 }); + } + } + + validateToken(resource: string, token: string) { + return authorizationServer.validateToken(resource, token, this.env); + } } -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: session.userId, - metadata: {}, - scope: approved.request.scope, - props: { userId: session.userId }, -}); -approved.headers.set("Location", redirectTo); -return new Response(null, { status: 302, headers: approved.headers }); ``` +
+ 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. `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. @@ -81,34 +156,145 @@ Ask for consent first, then hand the user to the provider: | `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 -// POST /authorize: the user approved this client -const approved = await oauth.approveConsent(request, handle); -const { state, headers } = await oauth.beginUpstream(approved.request, { - data: { verifier }, // returned at the callback, never sent to the provider - headers: approved.headers, -}); -headers.set("Location", providerAuthorizeUrl({ state })); -return new Response(null, { status: 302, headers }); - -// GET /callback -const { request: original, data, headers: clear } = - await oauth.finishUpstream<{ verifier: string }>(request); -const upstream = await exchangeProviderCode( - new URL(request.url).searchParams.get("code")!, - data.verifier, -); -const { redirectTo } = await oauth.completeAuthorization({ - request: original, - userId: upstream.userId, - metadata: {}, - scope: original.scope, - props: { providerToken: upstream.accessToken }, // encrypted at rest +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, }); -clear.set("Location", redirectTo); -return new Response(null, { status: 302, headers: clear }); + +// 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}.

+
+ + + +
`; +} + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +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, + }); + } + + // 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); + + 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, + }); + + 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 }); +} + +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 }>(); + + 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 }); +} + +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); + } + + validateToken(resource: string, token: string) { + return authorizationServer.validateToken(resource, token, this.env); + } +} ``` +
+ 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. ## Checks on the MCP server @@ -117,17 +303,62 @@ Refer to [Signing in through another provider](https://github.com/cloudflare/wor `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 -handler: { - fetch(request, env, ctx) { - if (!ctx.auth.scope.includes("mcp:read")) { - return insufficientScope(ctx.auth, ["mcp:read"]); - } - return mcpHandler(request, env, ctx); +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); + }, + }, +}); ``` + + Props can hold upstream access tokens. Do not log them or return them from tools. ## Security checklist 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 d39e4f2fefa..06c72128efb 100644 --- a/src/content/docs/agents/model-context-protocol/protocol/authorization.mdx +++ b/src/content/docs/agents/model-context-protocol/protocol/authorization.mdx @@ -280,34 +280,7 @@ For every option, refer to the [Workers OAuth Provider documentation](https://gi 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. -```ts -import { getMcpAuthContext } from "agents/mcp/server"; -import { McpServer } from "@modelcontextprotocol/server"; - -type AuthContext = { - claims: { sub: string; name: string; email: string }; - permissions: string[]; -}; - -function createServer() { - const server = new McpServer({ name: "Auth Demo", version: "1.0.0" }); - - server.registerTool( - "whoami", - { description: "Get the current user" }, - async () => { - const props = getMcpAuthContext()?.props as AuthContext | undefined; - return { - content: [ - { type: "text", text: `Hello, ${props?.claims.name ?? "anonymous"}!` }, - ], - }; - }, - ); - - return server; -} -``` +The `whoami` tool in the [MCP server example](#make-your-mcp-server-a-resource-server) reads `props.name` this way. Props often hold upstream access tokens. Do not log or return them. @@ -315,10 +288,28 @@ Props often hold upstream access tokens. Do not log or return them. 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. + + ```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"; + +// Stored with completeAuthorization() when the user signed in. +type AuthProps = { userId: string; permissions: string[] }; + +interface Env { + AUTH_SERVER: AuthorizationServerBinding; +} + function createServer() { const server = new McpServer({ name: "Permissions Demo", version: "1.0.0" }); - const props = getMcpAuthContext()?.props as AuthContext | undefined; + const props = getMcpAuthContext()?.props as AuthProps; server.registerTool( "publicTool", @@ -328,15 +319,15 @@ function createServer() { }), ); + // 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")) { + if (!props.permissions.includes("admin")) { return { - content: [ - { type: "text", text: "Permission denied: requires admin" }, - ], + isError: true, + content: [{ type: "text", text: "Permission denied: requires admin" }], }; } return { @@ -345,7 +336,8 @@ function createServer() { }, ); - if (props?.permissions.includes("special_feature")) { + // Register conditionally: the model never sees the tool. + if (props.permissions.includes("special_feature")) { server.registerTool( "specialTool", { description: "Special feature" }, @@ -357,8 +349,29 @@ function createServer() { return server; } + +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); + }, + }, +}); ``` + + 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. 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()`. 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 20579ee592e..654be31f6bb 100644 --- a/src/content/docs/agents/model-context-protocol/protocol/tools.mdx +++ b/src/content/docs/agents/model-context-protocol/protocol/tools.mdx @@ -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; +} - return { - content: [{ type: "text", text: JSON.stringify(user, null, 2) }], - }; +const handler = createMcpHandler(createServer); + +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,11 +224,13 @@ 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; ``` @@ -179,21 +242,36 @@ export default { ```ts +import { McpServer } from "@modelcontextprotocol/server"; +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; import { env } from "cloudflare:workers"; -import { getMcpAuthContext } from "agents/mcp/server"; - -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}` }], - }; + +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; ``` diff --git a/src/content/docs/agents/model-context-protocol/protocol/transport.mdx b/src/content/docs/agents/model-context-protocol/protocol/transport.mdx index a681a35c0de..5931ca87332 100644 --- a/src/content/docs/agents/model-context-protocol/protocol/transport.mdx +++ b/src/content/docs/agents/model-context-protocol/protocol/transport.mdx @@ -82,20 +82,51 @@ To require OAuth, wrap the handler in an `OAuthResourceServer` from [Workers OAu ```ts -import { OAuthResourceServer } from "@cloudflare/workers-oauth-provider"; -import { createMcpHandler } from "agents/mcp/server"; +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, // the URL MCP clients connect to + resource: env.MCP_SERVER_URL, // tokens for any other resource are rejected authorization_servers: [env.AUTH_SERVER_URL], }, - validateToken: (env) => env.AUTH_SERVER.validateToken, // Service Binding to your authorization server + 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); }, }, 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 2f3cdae96fb..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,12 +8,16 @@ products: - agents --- +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/server"; import { createMcpHandler } from "agents/mcp/server"; @@ -66,6 +70,8 @@ export default { } satisfies ExportedHandler; ``` + + ## Configuration | Field | Description | @@ -74,18 +80,18 @@ export default { | `recipient` | Wallet address to receive payments | | `facilitator` | Payment facilitator URL (use `https://x402.org/facilitator`) | -## paidTool signature +## paidTool parameters -```ts -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.