跳转到主要内容

MCP Server

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. The deprecated API is documented in the McpAgent reference.

Install dependencies

@modelcontextprotocol/server is an optional peer dependency of agents, so your package manager does not install it for you. Use the exact version required by your installed Agents release.

createMcpHandler

createMcpHandler creates a callable stateless MCP request handler from an MCP SDK v2 server factory. Invoke it from a Worker's object fetch() export or compose it inside another handler.

import {
	createMcpHandler,
	type CreateMcpHandlerOptions,
	type StatelessMcpHandler,
} from "agents/mcp/server";
import type { McpServerFactory } from "@modelcontextprotocol/server";

function createMcpHandler(
	factory: McpServerFactory,
	options?: CreateMcpHandlerOptions,
): StatelessMcpHandler;

Parameters

  • factory creates a fresh McpServer or Server from @modelcontextprotocol/server. It can be synchronous or asynchronous.
  • options combines Agents Worker options with supported upstream SDK v2 handler options.

The factory receives this request context:

interface McpRequestContext {
	era: "modern" | "legacy";
	authInfo?: AuthInfo;
	requestInfo?: Request;
}

A zero-argument factory remains valid.

Example

import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { z } from "zod";

function createServer() {
	const server = new McpServer({
		name: "hello-server",
		version: "1.0.0",
	});

	server.registerTool(
		"hello",
		{
			description: "Return a greeting",
			inputSchema: z.object({ name: z.string().optional() }),
		},
		async ({ name }) => ({
			content: [{ type: "text", text: `Hello, ${name ?? "World"}!` }],
		}),
	);

	return server;
}

export default {
	fetch(request, env, ctx) {
		return createMcpHandler(createServer)(request, env, ctx);
	},
} satisfies ExportedHandler;

Pass the factory itself. Do not create one global server instance or pass a constructed SDK v2 server directly.

CreateMcpHandlerOptions

The following options are available:

Option Type Default Description
route string "/mcp" Exact path handled by the Worker wrapper
corsOptions CORSOptions | false Wildcard CORS CORS response headers, or false to remove them
allowedHostnames string[] Localhost or workers.dev route Optional Host restriction for custom domains
allowedOriginHostnames string[] | "*" Localhost, workers.dev, or concrete CORS Origin Browser Origin restriction, or explicit middleware delegation
authContext McpAuthContext Execution context props Application props returned by getMcpAuthContext()
legacy "stateless" | "reject" "stateless" legacy compatibility or stateless-only rejection
responseMode "auto" | "json" | "sse" "auto" stateless request response shaping
onerror (error: Error) => void None Out-of-band error reporting
maxSubscriptions number 1,024 Maximum concurrent listen streams
keepAliveMs number 15,000 Keepalive interval for listen streams

SDK v1 transport options do not apply to this handler. It rejects options such as transport, storage, sessionIdGenerator, eventStore, and enableJsonResponse.

Use responseMode: "json" instead of enableJsonResponse: true. JSON mode drops notifications emitted before a final result.

Factory lifecycle

The handler creates one MCP server for each request. This follows the draft protocol model, where version, identity, and capabilities travel with every request rather than through a protocol session.

Application data can still be durable. Store cross-request data behind an authenticated handle in a Durable Object, D1, KV, or R2 rather than an MCP session ID.

Elicitation with a stateless handler

Elicitation through a stateless handler returns input_required and completes through multi-round-trip requests (MRTR). On each retry, the SDK echoes the latest requestState and sends responses for the immediately preceding input round. It does not accumulate earlier inputResponses. The Worker does not remain suspended while a user responds.

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 for a two-round tool flow.

Origin validation and CORS

The Workers wrapper validates every present browser Origin. It rejects malformed, opaque, and non-HTTP Origins with 403. Origin-less non-browser MCP clients remain valid.

The default allowlist includes localhost-class Origins, the endpoint's workers.dev hostname, and a concrete hostname from corsOptions.origin. The handler also applies matching Host checks to localhost and workers.dev endpoints. This keeps local DNS rebinding protection without requiring a separate Origin list for the common Workers routes.

For a custom domain with wildcard CORS, set allowedHostnames and allowedOriginHostnames explicitly. If corsOptions.origin is a concrete URL, the handler derives its Origin hostname automatically:

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 handler(request, env, ctx);
	},
} satisfies ExportedHandler<Env>;

Allowlist values are hostnames without a scheme or port. Origin matching ignores scheme and port.

Set allowedOriginHostnames: "*" only when trusted middleware validates Origins before calling the handler. This value turns off the handler Origin check, including malformed and opaque Origin rejection. MCP HTTP servers must validate browser Origins.

CORS response headers are not authentication. Protect the MCP endpoint with OAuth or another authentication layer.

The handler does not infer a Host allowlist from request.url. If a deployment accepts arbitrary Host values, validate them before calling the handler. Local servers outside Cloudflare Workers should follow the upstream SDK DNS rebinding guidance.

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.

This compatibility path does not provide a complete session transport:

  • Each POST creates a new server and transport.
  • HTTP GET and DELETE return 405.
  • No MCP session ID persists.
  • Pushed elicitation, sampling, and roots requests fail immediately.
  • 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.

Return value

createMcpHandler returns a StatelessMcpHandler. It is callable and exposes request and notification controls:

interface StatelessMcpHandler {
	(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response>;

	fetch(
		request: Request,
		options?: McpHandlerRequestOptions,
	): Promise<Response>;

	notify: {
		toolsChanged(): void;
		promptsChanged(): void;
		resourcesChanged(): void;
		resourceUpdated(uri: string): void;
	};
}

type McpHandlerRequestOptions = {
	authInfo?: AuthInfo;
	parsedBody?: unknown;
};

Invoke the handler

Call the handler from a Worker's object fetch() export:

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 handler(request, env, ctx);
	},
} satisfies ExportedHandler<Env>;

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:

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<AuthInfo | undefined> {
	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<Env>;

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

The notify methods publish typed change events to matching open subscriptions/listen streams:

Method MCP notification
notify.toolsChanged() notifications/tools/list_changed
notify.promptsChanged() notifications/prompts/list_changed
notify.resourcesChanged() notifications/resources/list_changed
notify.resourceUpdated(uri) notifications/resources/updated

Calling a notifier when no matching subscription is open is a no-op.

Keep one handler for notifications

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. This Worker also has an admin route, protected by an ADMIN_TOKEN secret, that tells subscribed clients to fetch the tool list again:

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 {
	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<Env>;

Notifications are isolate-local. A notification published in one Worker isolate does not reach a subscription stream running in another isolate.

Authentication context

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 to build the authorization 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<AuthProps>;
}

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<Env, AuthProps>({
	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);
		},
	},
});

The resource server passes the props stored with completeAuthorization() as ctx.props, and getMcpAuthContext() returns them inside your server:

interface McpAuthContext {
	props: Record<string, unknown>;
}

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:

import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server";
import { z } from "zod";

type Props = { userId: string; role: "admin" | "member" };

function createServer() {
	const server = new McpServer({ name: "my-server", version: "1.0.0" });
	const props = getMcpAuthContext()?.props as Props | undefined;

	server.registerTool(
		"whoami",
		{ description: "Return the signed-in user" },
		async () => ({
			content: [{ type: "text", text: props?.userId ?? "anonymous" }],
		}),
	);

	// Only admins see this tool.
	if (props?.role === "admin") {
		server.registerTool(
			"deleteUser",
			{ description: "Delete a user", inputSchema: { userId: z.string() } },
			async ({ userId }) => ({
				content: [{ type: "text", text: `Deleted ${userId}` }],
			}),
		);
	}

	return server;
}

// Replace with your own session or token check.
async function authenticate(request: Request): Promise<Props | undefined> {
	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<Env>;

Props often hold upstream access tokens. Do not log or return them.