Skip to content
Open
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
22 changes: 22 additions & 0 deletions examples/mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# MCP Server Example

This example demonstrates how to use `WebStandardStreamableHTTPServerTransport` to create an unauthenticated stateless MCP server.

In this example we do not use the `agents` package, but instead use the `@modelcontextprotocol/sdk` package directly to create an MCP server that "just works" on Cloudflare Workers.

This is THE simplest way to get started with MCP on Cloudflare.

## Usage

```bash
npm install
npm run dev
```

## Testing

You can test the MCP server using the MCP Inspector or any MCP client that supports the `streamable-http` transport.

## Adding State

To create a stateful MCP server, you can use an `Agent` to keep the state of the session/transport. See the [`mcp-elicitations`](../mcp-elicitation) example for more information.
9 changes: 9 additions & 0 deletions examples/mcp-server/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types env.d.ts --include-runtime false` (hash: b739a9c19cff1463949c4db47674ed86)
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/index");
}
interface Env {}
}
interface Env extends Cloudflare.Env {}
13 changes: 13 additions & 0 deletions examples/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@cloudflare/agents-mcp-server",
"description": "zero config stateless MCP Server on Cloudflare",
"author": "Matt Carey <mcarey@cloudflare.com>",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types env.d.ts --include-runtime false"
}
}
54 changes: 54 additions & 0 deletions examples/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";

const server = new McpServer({
name: "Hello 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"
}
]
};
}
);

const transport = new WebStandardStreamableHTTPServerTransport();
server.connect(transport);

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Accept, mcp-session-id, mcp-protocol-version",
"Access-Control-Expose-Headers": "mcp-session-id",
"Access-Control-Max-Age": "86400"
};

function withCors(response: Response): Response {
for (const [key, value] of Object.entries(corsHeaders)) {
response.headers.set(key, value);
}
return response;
}

export default {
fetch: async (request: Request, _env: Env, _ctx: ExecutionContext) => {
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
return withCors(await transport.handleRequest(request));
}
};
3 changes: 3 additions & 0 deletions examples/mcp-server/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json"
}
11 changes: 11 additions & 0 deletions examples/mcp-server/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compatibility_date": "2025-10-08",
"compatibility_flags": ["nodejs_compat"],
"main": "src/index.ts",
"name": "mcp-server",
"observability": {
"logs": {
"enabled": true
}
}
}
2 changes: 0 additions & 2 deletions examples/mcp-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

This example demonstrates how to use `createMcpHandler` to create an unauthenticated stateless MCP server.

This is THE simplest way to get started with MCP on Cloudflare.

## Usage

```bash
Expand Down
68 changes: 17 additions & 51 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@cloudflare/vite-plugin": "^1.19.0",
"@cloudflare/vitest-pool-workers": "^0.11.1",
"@cloudflare/workers-types": "^4.20251221.0",
"@modelcontextprotocol/sdk": "1.23.0",
"@modelcontextprotocol/sdk": "1.25.1",
"@openai/agents": "^0.3.7",
"@openai/agents-extensions": "^0.3.7",
"@types/node": "^25.0.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@modelcontextprotocol/sdk": "1.23.0",
"@modelcontextprotocol/sdk": "1.25.1",
"cron-schedule": "^6.0.0",
"json-schema": "^0.4.0",
"json-schema-to-typescript": "^15.0.4",
Expand Down
22 changes: 21 additions & 1 deletion packages/agents/src/mcp/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,34 @@ export function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function getErrorCode(error: unknown): number | undefined {
if (
error &&
typeof error === "object" &&
"code" in error &&
typeof (error as { code: unknown }).code === "number"
) {
return (error as { code: number }).code;
}
return undefined;
}

export function isUnauthorized(error: unknown): boolean {
const code = getErrorCode(error);
if (code === 401) return true;

const msg = toErrorMessage(error);
return msg.includes("Unauthorized") || msg.includes("401");
}

// MCP SDK change (v1.24.0, commit 6b90e1a):
// - Old: Error POSTing to endpoint (HTTP 404): Not Found
// - New: StreamableHTTPError with code: 404 and message Error POSTing to endpoint: Not Found
export function isTransportNotImplemented(error: unknown): boolean {
const code = getErrorCode(error);
if (code === 404 || code === 405) return true;

const msg = toErrorMessage(error);
// Treat common "not implemented" surfaces as transport not supported
return (
msg.includes("404") ||
msg.includes("405") ||
Expand Down
8 changes: 4 additions & 4 deletions packages/agents/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import {
JSONRPCMessageSchema,
isJSONRPCError,
isJSONRPCResponse,
isJSONRPCErrorResponse,
isJSONRPCResultResponse,
type ElicitResult
} from "@modelcontextprotocol/sdk/types.js";
import type { Connection, ConnectionContext } from "../";
Expand Down Expand Up @@ -312,7 +312,7 @@ export abstract class McpAgent<
message: JSONRPCMessage
): Promise<boolean> {
// Check if this is a response to an elicitation request
if (isJSONRPCResponse(message) && message.result) {
if (isJSONRPCResultResponse(message) && message.result) {
const requestId = message.id?.toString();
if (!requestId || !requestId.startsWith("elicit_")) return false;

Expand All @@ -331,7 +331,7 @@ export abstract class McpAgent<
}

// Check if this is an error response to an elicitation request
if (isJSONRPCError(message)) {
if (isJSONRPCErrorResponse(message)) {
const requestId = message.id?.toString();
if (!requestId || !requestId.startsWith("elicit_")) return false;

Expand Down
10 changes: 5 additions & 5 deletions packages/agents/src/mcp/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import {
type MessageExtraInfo,
type RequestInfo,
isJSONRPCError,
isJSONRPCErrorResponse,
isJSONRPCRequest,
isJSONRPCResponse,
isJSONRPCResultResponse,
type JSONRPCMessage,
JSONRPCMessageSchema,
type RequestId
Expand Down Expand Up @@ -326,7 +326,7 @@ export class StreamableHTTPServerTransport implements Transport {
if (!agent) throw new Error("Agent was not found in send");

let requestId = options?.relatedRequestId;
if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
// If the message is a response, use the request ID from the message
requestId = message.id;
}
Expand All @@ -336,7 +336,7 @@ export class StreamableHTTPServerTransport implements Transport {
// Those will be sent via dedicated response SSE streams
if (requestId === undefined) {
// For standalone SSE streams, we can only send requests and notifications
if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
throw new Error(
"Cannot send a response on a standalone SSE stream unless resuming a previous client request"
);
Expand Down Expand Up @@ -385,7 +385,7 @@ export class StreamableHTTPServerTransport implements Transport {

let shouldClose = false;

if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
this._requestResponseMap.set(requestId, message);
const relatedIds = connection.state?.requestIds ?? [];
// Check if we have responses for all requests using this connection
Expand Down
Loading
Loading