The Model Context Protocol (MCP) specification defines two standard transport mechanisms ↗ for communication between clients and servers:
- stdio — Communication over standard in and standard out, designed for local MCP connections.
- Streamable HTTP — The standard transport method for remote MCP connections, introduced ↗ in March 2025. It uses a single HTTP endpoint for bidirectional messaging.
MCP servers built with the Agents SDK use createMcpHandler to handle Streamable HTTP transport.
Use createMcpHandler to create an MCP server that handles Streamable HTTP transport. This is the recommended approach for new MCP servers.
You can use the "Deploy to Cloudflare" button to create a remote MCP server.
Create an MCP server using createMcpHandler. View the complete example on GitHub ↗.
import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
function createServer() {
const server = new McpServer({
name: "My MCP Server",
version: "1.0.0",
});
server.registerTool(
"hello",
{
description: "Returns a greeting message",
inputSchema: { name: z.string().optional() },
},
async ({ name }) => {
return {
content: [{ text: `Hello, ${name ?? "World"}!`, type: "text" }],
};
},
);
return server;
}
export default {
fetch(request, env, ctx) {
return createMcpHandler(createServer)(request, env, ctx);
},
};import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
function createServer() {
const server = new McpServer({
name: "My MCP Server",
version: "1.0.0",
});
server.registerTool(
"hello",
{
description: "Returns a greeting message",
inputSchema: { name: z.string().optional() },
},
async ({ name }) => {
return {
content: [{ text: `Hello, ${name ?? "World"}!`, type: "text" }],
};
},
);
return server;
}
export default {
fetch(request, env, ctx) {
return createMcpHandler(createServer)(request, env, ctx);
},
} satisfies ExportedHandler;If your MCP server implements authentication & authorization using the Workers OAuth Provider ↗ library, use createMcpHandler with the apiRoute and apiHandler properties. View the complete example on GitHub ↗.
export default new OAuthProvider({
apiRoute: "/mcp",
apiHandler: createMcpHandler(createServer),
// ... other OAuth configuration
});export default new OAuthProvider({
apiRoute: "/mcp",
apiHandler: createMcpHandler(createServer),
// ... other OAuth configuration
});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 for the staged migration. Refer to McpAgent: Stream resumability for existing stream behavior.
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 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.
Create your McpAgent with the tools you want to expose:
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: "MyMCP", version: "1.0.0" });
initialState = { 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}`,
},
],
};
},
);
}
}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<Env, State> {
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}`,
},
],
};
},
);
}
}In your Agent, call addMcpServer() with the Durable Object binding in onStart():
import { AIChatAgent } from "@cloudflare/ai-chat";
export class Chat extends AIChatAgent {
async onStart() {
// 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 });
}
}import { AIChatAgent } from "@cloudflare/ai-chat";
export class Chat extends AIChatAgent<Env> {
async onStart(): Promise<void> {
// 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 for details). This makes it safe to call in onStart().
In your wrangler.jsonc, define bindings for both Durable Objects:
{
"durable_objects": {
"bindings": [
{ "name": "Chat", "class_name": "Chat" },
{ "name": "MyMCP", "class_name": "MyMCP" },
],
},
"migrations": [
{
"new_sqlite_classes": ["MyMCP", "Chat"],
"tag": "v1",
},
],
}Route requests to your Chat agent:
import { routeAgentRequest } from "agents";
export default {
async fetch(request, env, ctx) {
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 });
},
};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<Env>;Since RPC transport does not have an OAuth flow, you can pass user context directly as props:
await this.addMcpServer("my-mcp", this.env.MyMCP, {
props: { userId: "user-123", role: "admin" },
});await this.addMcpServer("my-mcp", this.env.MyMCP, {
props: { userId: "user-123", role: "admin" },
});Your McpAgent can then access these props:
export class MyMCP extends McpAgent {
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}` }],
};
});
}
}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.
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:
export class MyMCP extends McpAgent {
server = new McpServer({ name: "MyMCP", version: "1.0.0" });
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" }],
};
},
);
}
}export class MyMCP extends McpAgent<Env, State> {
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" }],
};
},
);
}
}| 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 |
If the endpoint does not use legacy stateful features, migrate directly to a stateless server factory from @modelcontextprotocol/server and pass it to createMcpHandler.
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.
Refer to Migrate to MCP SDK v2 for the feature mapping, dual-era routing, and rollout steps.
You can test your MCP server using an MCP client that supports remote connections, or use mcp-remote ↗, an adapter that lets MCP clients that only support local connections work with remote MCP servers.
Follow this guide for instructions on how to connect to your remote MCP server to Claude Desktop, Cursor, Windsurf, and other MCP clients.