Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 7 additions & 46 deletions examples/mcp-elicitation/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,22 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
createMcpHandler,
type TransportState,
WorkerTransport
} from "agents/mcp";
import { McpAgent } from "agents/mcp";
import * as z from "zod";
import { Agent, getAgentByName } from "agents";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker-provider.js";
import { env } from "cloudflare:workers";

const STATE_KEY = "mcp_transport_state";

interface State {
counter: number;
}

export class MyAgent extends Agent<Cloudflare.Env, State> {
server = new McpServer(
{
name: "test",
version: "1.0.0"
},
{
jsonSchemaValidator: new CfWorkerJsonSchemaValidator()
}
);

transport = new WorkerTransport({
sessionIdGenerator: () => this.name,
storage: {
get: () => {
return this.ctx.storage.kv.get<TransportState>(STATE_KEY);
},
set: (state: TransportState) => {
this.ctx.storage.kv.put<TransportState>(STATE_KEY, state);
}
}
export class MyAgent extends McpAgent<Cloudflare.Env, State> {
server = new McpServer({
name: "test",
version: "1.0.0"
});

initialState = {
counter: 0
};

onStart(): void | Promise<void> {
async init() {
this.server.registerTool(
"increase-counter",
{
Expand Down Expand Up @@ -113,19 +87,6 @@ export class MyAgent extends Agent<Cloudflare.Env, State> {
}
);
}

async onMcpRequest(request: Request) {
return createMcpHandler(this.server, {
transport: this.transport
})(request, this.env, {} as ExecutionContext);
}
}

export default {
async fetch(request: Request) {
const sessionId =
request.headers.get("mcp-session-id") ?? crypto.randomUUID();
const agent = await getAgentByName(env.MyAgent, sessionId);
return await agent.onMcpRequest(request);
}
};
export default MyAgent.serve("/mcp", { binding: "MyAgent" });
19 changes: 6 additions & 13 deletions examples/mcp-worker-authenticated/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";
import { AuthHandler } from "./auth-handler";

function createServer() {
const server = new McpServer({
Expand Down Expand Up @@ -77,21 +76,15 @@
* This handler will receive requests that have a valid access token
*/
const apiHandler = {
async fetch(request: Request, env: unknown, ctx: ExecutionContext) {
//create the server instance every request
const server = createServer();
return createMcpHandler(server)(request, env, ctx);
}
fetch: createMcpHandler(createServer, { route: "/mcp" })
};

export default new OAuthProvider({
authorizeEndpoint: "/authorize",
tokenEndpoint: "/oauth/token",
clientRegistrationEndpoint: "/oauth/register",

apiRoute: "/mcp",
apiHandler: apiHandler,

//@ts-expect-error
defaultHandler: AuthHandler
provider: AccessProvider({

Check failure on line 85 in examples/mcp-worker-authenticated/src/index.ts

View workflow job for this annotation

GitHub Actions / check

Cannot find name 'AccessProvider'.

Check failure on line 85 in examples/mcp-worker-authenticated/src/index.ts

View workflow job for this annotation

GitHub Actions / check

Object literal may only specify known properties, and 'provider' does not exist in type 'OAuthProviderOptions'.
id: "app-id",
secret: "app-secret",
redirectUri: "https://your-app.com/oauth/callback"
})
});
6 changes: 1 addition & 5 deletions examples/mcp-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,5 @@ function createServer() {
}

export default {
fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {
//create the server instance every request
const server = createServer();
return createMcpHandler(server)(request, env, ctx);
}
fetch: createMcpHandler(createServer)
};
18 changes: 1 addition & 17 deletions examples/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,22 +104,6 @@ export class MyMCP extends McpAgent<Env, State, {}> {

export default {
fetch(request: Request, env: unknown, ctx: ExecutionContext) {
const url = new URL(request.url);

// support both legacy SSE and new streamable-http

if (url.pathname.startsWith("/sse")) {
return MyMCP.serveSSE("/sse", { binding: "MyMCP" }).fetch(
request,
env,
ctx
);
}

if (url.pathname.startsWith("/mcp")) {
return MyMCP.serve("/mcp", { binding: "MyMCP" }).fetch(request, env, ctx);
}

return new Response("Not found", { status: 404 });
return MyMCP.serve("/mcp", { binding: "MyMCP" }).fetch(request, env, ctx);
}
};
131 changes: 131 additions & 0 deletions packages/agents/MCP_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# MCP Migration Guide

This guide covers breaking changes in the MCP server refactor.

## Summary

McpAgent now uses the MCP SDK's `WebStandardStreamableHTTPServerTransport` directly instead of a custom transport layer. The WebSocket bridging between Worker and Durable Object has been removed. McpAgent handles HTTP requests directly in the DO.

## Breaking Changes

### 1. Legacy SSE transport removed

`McpAgent.serveSSE()` and `McpAgent.mount()` have been removed. Only Streamable HTTP transport is supported via `McpAgent.serve()`.

**Before:**

```ts
MyMCP.serveSSE("/sse", { binding: "MyMCP" });
MyMCP.mount("/sse", { binding: "MyMCP" });
```

**After:**

```ts
MyMCP.serve("/mcp", { binding: "MyMCP" });
```

### 2. `WorkerTransport` removed

The custom `WorkerTransport` class has been removed. For low-level stateless MCP servers, use `createMcpHandler` (which now uses the SDK's `WebStandardStreamableHTTPServerTransport` under the hood) or use the SDK transport directly.

**Before:**

```ts
import { WorkerTransport } from "agents/mcp";
```

**After:**

```ts
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
```

### 3. `createMcpHandler` options simplified

The handler no longer accepts transport-specific passthrough options. It creates a stateless transport per request.

**Removed options:** `sessionIdGenerator`, `enableJsonResponse`, `onsessioninitialized`, `storage`, `transport`

**Kept options:** `route`, `corsOptions`, `authContext`

For stateful MCP servers with session management, use `McpAgent`.

### 4. Internal transport classes removed

The following internal classes are no longer exported:

- `McpSSETransport`
- `StreamableHTTPServerTransport` (the custom DO-based one)
- `WorkerTransport`
- `TransportState`, `MCPStorageApi`

### 5. Internal headers removed

`MCP_HTTP_METHOD_HEADER` (`cf-mcp-method`) and `MCP_MESSAGE_HEADER` (`cf-mcp-message`) are no longer used. The DO now handles HTTP directly without WebSocket bridging.

### 6. `createMcpHandler` requires a factory function

`createMcpHandler` now requires a factory function `() => McpServer | Server` instead of a direct server instance. The factory is called per request in stateless mode, ensuring clean server state for each request.

**Before:**

```ts
const server = new McpServer({ name: "My Server", version: "1.0.0" });
server.registerTool("add", { ... }, async () => { ... });
export default { fetch: createMcpHandler(server) };
```

**After:**

```ts
function createServer() {
const server = new McpServer({ name: "My Server", version: "1.0.0" });
server.registerTool("add", { ... }, async () => { ... });
return server;
}
export default { fetch: createMcpHandler(createServer) };
```

### 7. Session routing changes

Session management is now handled entirely by the MCP SDK transport inside each Durable Object. The Worker-level routing in `McpAgent.serve()` uses the `mcp-session-id` header to route requests to the correct DO instance. Session validation (accept header checks, content-type validation, JSON-RPC parsing) happens inside the DO, not in the Worker.

This means:

- An initialization request with a session ID will route to that DO and succeed (the DO is the session)
- A non-init request to an uninitialized DO will return a 400 error from the SDK transport
- Session IDs are DO instance names (not random transport-generated IDs)

### 8. `McpAgent.serve()` options moved

`McpAgentServeOptions` replaces the old `ServeOptions` type:

**Removed options:** `prefix`, `onError`, `onSessionCreated`

**Kept options:** `binding` (default `"MCP_OBJECT"`), `corsOptions`, `jurisdiction`

## Architecture Overview

```
Worker (McpAgent.serve())
└── Routes by mcp-session-id header → Durable Object
└── McpAgent (DO)
├── onStart() — register tools, resources, prompts
├── _setupMcp() — create SDK transport, connect server
└── onRequest() → transport.handleRequest(request)
└── WebStandardStreamableHTTPServerTransport (from SDK)
```

No WebSocket bridging. The DO handles HTTP requests directly and returns responses. State persists across DO hibernation via storage-backed initialize replay.

## Security: CVE-2026-25536

This refactor addresses [CVE-2026-25536](https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-345p-7cg4-v4c7), a cross-client data leak in the MCP TypeScript SDK (versions 1.10.0–1.25.3) caused by reusing server or transport instances across clients.

**How our APIs prevent this:**

- **`McpAgent`** — each client session maps to its own Durable Object with its own server and transport instance. One client per DO, no sharing possible.
- **`createMcpHandler`** — requires a factory function (not a server instance). A fresh server and transport are created per request. It is impossible to accidentally reuse a server across requests.

If you are using the MCP SDK's `WebStandardStreamableHTTPServerTransport` directly (outside of our APIs), you must create a new server and transport per request/session. Never reuse either across clients.
27 changes: 27 additions & 0 deletions packages/agents/src/mcp/cf-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker-provider.js";

/**
* Inject the Cloudflare Worker-compatible JSON Schema validator into an MCP
* server instance. The default Ajv validator uses `new Function()` which is
* blocked in Workers. This replaces it with `@cfworker/json-schema` which
* validates without code generation.
*
* Works with both `McpServer` (high-level) and `Server` (low-level).
*/
export function injectCfWorkerValidator(server: McpServer | Server): void {
// McpServer wraps Server as .server; raw Server is the object itself
const innerServer: Server =
"server" in server && typeof (server as McpServer).server === "object"
? (server as McpServer).server
: (server as Server);

// _jsonSchemaValidator is private, but we need to override it to avoid
// the Ajv "Code generation from strings disallowed" error in Workers.
Object.defineProperty(innerServer, "_jsonSchemaValidator", {
value: new CfWorkerJsonSchemaValidator(),
writable: true,
configurable: true
});
}
1 change: 0 additions & 1 deletion packages/agents/src/mcp/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,6 @@ export class MCPClientConnection {
_request: ElicitRequest
): Promise<ElicitResult> {
// Elicitation handling must be implemented by the platform
// For MCP servers, this should be handled by McpAgent.elicitInput()
throw new Error(
"Elicitation handler must be implemented for your platform. Override handleElicitationRequest method."
);
Expand Down
Loading
Loading