agents
Version:
A home for your AI agents
1,642 lines • 63.6 kB
JavaScript
import "../types.js";
import { c as RPC_DO_PREFIX, i as normalizeServerId, n as MCP_SERVER_ID_MAX_LENGTH, o as RPCClientTransport, s as RPCServerTransport } from "../client-zqKcsyFa.js";
import { Agent, getAgentByName, getCurrentAgent } from "../index.js";
import { n as getMcpAuthContext, r as runWithAuthContext, t as createStatelessMcpHandler } from "../handler-stateless-CIkKPETH.js";
import { SSEClientTransport, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { ElicitRequestSchema, InitializeRequestSchema, JSONRPCMessageSchema, SUPPORTED_PROTOCOL_VERSIONS, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from "@modelcontextprotocol/sdk/types.js";
import { McpServer, Server } from "@modelcontextprotocol/server";
import { McpServer as McpServer$1 } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Server as Server$1 } from "@modelcontextprotocol/sdk/server/index.js";
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport$1 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
//#region src/mcp/sse-keepalive.ts
/**
* SSE keepalive for the retained McpAgent WebSocket-to-SSE bridge.
*
* SDK-backed HTTP transports own their keepalive timers. This helper remains
* only for the custom bridge, where long-running POST tool calls can otherwise
* sit silent until Cloudflare's edge closes the idle stream.
*
* See cloudflare/agents#1583.
*/
/** Interval between bridge SSE keepalive comment frames, in ms. */
const KEEPALIVE_INTERVAL_MS = 25e3;
/** SSE comment frame the parser drops before any event dispatch. */
const KEEPALIVE_FRAME = ": keepalive\n\n";
/**
* Start an SSE keepalive on `writer`. Returns a `clearInterval` handle
* that the stream cleanup must invoke when the stream closes.
*/
function startKeepalive(writer, encoder) {
const handle = setInterval(() => {
writer.write(encoder.encode(KEEPALIVE_FRAME)).catch(() => clearInterval(handle));
}, KEEPALIVE_INTERVAL_MS);
return handle;
}
//#endregion
//#region src/mcp/utils.ts
/**
* Since we use WebSockets to bridge the client to the
* MCP transport in the Agent, we use this header to signal
* the method of the original request the user made, while
* leaving the WS Upgrade request as GET.
*/
const MCP_HTTP_METHOD_HEADER = "cf-mcp-method";
/**
* Since we use WebSockets to bridge the client to the
* MCP transport in the Agent, we use this header to include
* the original request body.
*/
const MCP_MESSAGE_HEADER = "cf-mcp-message";
const MAXIMUM_MESSAGE_SIZE_BYTES = 4 * 1024 * 1024;
function unsupportedProtocolVersionResponse(request) {
const protocolVersion = request.headers.get("mcp-protocol-version");
if (protocolVersion === null || SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) return;
return Response.json({
error: {
code: -32e3,
message: `Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})`
},
id: null,
jsonrpc: "2.0"
}, { status: 400 });
}
const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
let pathname = basePath;
if (basePath === "/") pathname = "/*";
const basePattern = new URLPattern({ pathname });
return async (request, ctx) => {
const url = new URL(request.url);
if (basePattern.test(url)) {
if (request.method === "POST") {
const acceptHeader = request.headers.get("accept");
if (!acceptHeader?.includes("application/json") || !acceptHeader.includes("text/event-stream")) {
const body = JSON.stringify({
error: {
code: -32e3,
message: "Not Acceptable: Client must accept both application/json and text/event-stream"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 406 });
}
const ct = request.headers.get("content-type");
if (!ct || !ct.includes("application/json")) {
const body = JSON.stringify({
error: {
code: -32e3,
message: "Unsupported Media Type: Content-Type must be application/json"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 415 });
}
if (Number.parseInt(request.headers.get("content-length") ?? "0", 10) > MAXIMUM_MESSAGE_SIZE_BYTES) {
const body = JSON.stringify({
error: {
code: -32e3,
message: `Request body too large. Maximum size is ${MAXIMUM_MESSAGE_SIZE_BYTES} bytes`
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 413 });
}
let sessionId = request.headers.get("mcp-session-id");
let rawMessage;
try {
rawMessage = await request.json();
} catch (_error) {
const body = JSON.stringify({
error: {
code: -32700,
message: "Parse error: Invalid JSON"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 400 });
}
let arrayMessage;
if (Array.isArray(rawMessage)) arrayMessage = rawMessage;
else arrayMessage = [rawMessage];
let messages = [];
for (const msg of arrayMessage) if (!JSONRPCMessageSchema.safeParse(msg).success) {
const body = JSON.stringify({
error: {
code: -32700,
message: "Parse error: Invalid JSON-RPC message"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 400 });
}
messages = arrayMessage.map((msg) => JSONRPCMessageSchema.parse(msg));
const maybeInitializeRequest = messages.find((msg) => InitializeRequestSchema.safeParse(msg).success);
if (!!maybeInitializeRequest && sessionId) {
const body = JSON.stringify({
error: {
code: -32600,
message: "Invalid Request: Initialization requests must not include a sessionId"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 400 });
}
if (!!maybeInitializeRequest && messages.length > 1) {
const body = JSON.stringify({
error: {
code: -32600,
message: "Invalid Request: Only one initialization request is allowed"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 400 });
}
if (!maybeInitializeRequest) {
const unsupportedVersion = unsupportedProtocolVersionResponse(request);
if (unsupportedVersion) return unsupportedVersion;
}
if (!maybeInitializeRequest && !sessionId) {
const body = JSON.stringify({
error: {
code: -32e3,
message: "Bad Request: Mcp-Session-Id header is required"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 400 });
}
sessionId = sessionId ?? namespace.newUniqueId().toString();
const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, {
props: ctx.props,
jurisdiction: options.jurisdiction
});
const isInitialized = await agent.getInitializeRequest();
if (maybeInitializeRequest) await agent.setInitializeRequest(maybeInitializeRequest);
else if (!isInitialized) {
const body = JSON.stringify({
error: {
code: -32001,
message: "Session not found"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 404 });
}
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();
const existingHeaders = {};
request.headers.forEach((value, key) => {
existingHeaders[key] = value;
});
const req = new Request(request.url, { headers: {
...existingHeaders,
[MCP_HTTP_METHOD_HEADER]: "POST",
[MCP_MESSAGE_HEADER]: Buffer.from(JSON.stringify(messages)).toString("base64"),
Upgrade: "websocket"
} });
const ws = (await agent.fetch(req)).webSocket;
if (!ws) {
console.error("Failed to establish WebSocket connection");
await writer.close();
const body = JSON.stringify({
error: {
code: -32001,
message: "Failed to establish WebSocket connection"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 500 });
}
ws.accept();
if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) {
ws.close();
return new Response(null, {
headers: corsHeaders(request, options.corsOptions),
status: 202
});
}
const keepAlive = startKeepalive(writer, encoder);
ws.addEventListener("message", (event) => {
async function onMessage(event) {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const message = JSON.parse(data);
if (message.type !== "cf_mcp_agent_event") return;
await writer.write(encoder.encode(message.event));
if (message.close) {
clearInterval(keepAlive);
ws?.close();
await writer.close().catch(() => {});
}
} catch (error) {
console.error("Error forwarding message to SSE:", error);
}
}
onMessage(event).catch(console.error);
});
ws.addEventListener("error", (error) => {
async function onError(_error) {
clearInterval(keepAlive);
await writer.close().catch(() => {});
}
onError(error).catch(console.error);
});
ws.addEventListener("close", () => {
async function onClose() {
clearInterval(keepAlive);
await writer.close().catch(() => {});
}
onClose().catch(console.error);
});
return new Response(readable, {
headers: {
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Content-Type": "text/event-stream",
"mcp-session-id": sessionId,
...corsHeaders(request, options.corsOptions)
},
status: 200
});
} else if (request.method === "GET") {
if (!request.headers.get("accept")?.includes("text/event-stream")) {
const body = JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32e3,
message: "Not Acceptable: Client must accept text/event-stream"
},
id: null
});
return new Response(body, { status: 406 });
}
const sessionId = request.headers.get("mcp-session-id");
if (!sessionId) return new Response(JSON.stringify({
error: {
code: -32e3,
message: "Bad Request: Mcp-Session-Id header is required"
},
id: null,
jsonrpc: "2.0"
}), { status: 400 });
const unsupportedVersion = unsupportedProtocolVersionResponse(request);
if (unsupportedVersion) return unsupportedVersion;
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();
const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, {
props: ctx.props,
jurisdiction: options.jurisdiction
});
if (!await agent.getInitializeRequest()) return new Response(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32001,
message: "Session not found"
},
id: null
}), { status: 404 });
const existingHeaders = {};
request.headers.forEach((v, k) => {
existingHeaders[k] = v;
});
const ws = (await agent.fetch(new Request(request.url, { headers: {
...existingHeaders,
[MCP_HTTP_METHOD_HEADER]: "GET",
Upgrade: "websocket"
} }))).webSocket;
if (!ws) {
await writer.close();
return new Response("Failed to establish WS to DO", { status: 500 });
}
ws.accept();
ws.addEventListener("message", (event) => {
try {
async function onMessage(ev) {
const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);
const message = JSON.parse(data);
if (message.type !== "cf_mcp_agent_event") return;
await writer.write(encoder.encode(message.event));
}
onMessage(event).catch(console.error);
} catch (e) {
console.error("Error forwarding message to SSE:", e);
}
});
ws.addEventListener("error", () => {
writer.close().catch(() => {});
});
ws.addEventListener("close", () => {
writer.close().catch(() => {});
});
return new Response(readable, {
headers: {
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Content-Type": "text/event-stream",
"mcp-session-id": sessionId,
...corsHeaders(request, options.corsOptions)
},
status: 200
});
} else if (request.method === "DELETE") {
const unsupportedVersion = unsupportedProtocolVersionResponse(request);
if (unsupportedVersion) return unsupportedVersion;
const sessionId = request.headers.get("mcp-session-id");
if (!sessionId) return new Response(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32e3,
message: "Bad Request: Mcp-Session-Id header is required"
},
id: null
}), {
status: 400,
headers: corsHeaders(request, options.corsOptions)
});
const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { jurisdiction: options.jurisdiction });
if (!await agent.getInitializeRequest()) return new Response(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32001,
message: "Session not found"
},
id: null
}), {
status: 404,
headers: corsHeaders(request, options.corsOptions)
});
await agent._cf_scheduleDestroy();
return new Response(null, {
status: 204,
headers: corsHeaders(request, options.corsOptions)
});
}
}
const body = JSON.stringify({
error: {
code: -32e3,
message: "Not found"
},
id: null,
jsonrpc: "2.0"
});
return new Response(body, { status: 404 });
};
};
const createLegacySseHandler = (basePath, namespace, options = {}) => {
let pathname = basePath;
if (basePath === "/") pathname = "/*";
const basePattern = new URLPattern({ pathname });
const messagePattern = new URLPattern({ pathname: `${basePath}/message` });
return async (request, ctx) => {
const url = new URL(request.url);
if (request.method === "GET" && basePattern.test(url)) {
const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();
const endpointUrl = new URL(request.url);
endpointUrl.pathname = encodeURI(`${basePath}/message`);
endpointUrl.searchParams.set("sessionId", sessionId);
const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;
writer.write(encoder.encode(endpointMessage));
const agent = await getAgentByName(namespace, `sse:${sessionId}`, {
props: ctx.props,
jurisdiction: options.jurisdiction
});
const existingHeaders = {};
request.headers.forEach((value, key) => {
existingHeaders[key] = value;
});
const ws = (await agent.fetch(new Request(request.url, { headers: {
...existingHeaders,
[MCP_HTTP_METHOD_HEADER]: "SSE",
Upgrade: "websocket"
} }))).webSocket;
if (!ws) {
console.error("Failed to establish WebSocket connection");
await writer.close();
return new Response("Failed to establish WebSocket connection", { status: 500 });
}
ws.accept();
ws.addEventListener("message", (event) => {
async function onMessage(event) {
try {
const message = JSON.parse(event.data);
const result = JSONRPCMessageSchema.safeParse(message);
if (!result.success) return;
const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`;
await writer.write(encoder.encode(messageText));
} catch (error) {
console.error("Error forwarding message to SSE:", error);
}
}
onMessage(event).catch(console.error);
});
ws.addEventListener("error", (error) => {
async function onError(_error) {
try {
await writer.close();
} catch (_e) {}
}
onError(error).catch(console.error);
});
ws.addEventListener("close", () => {
async function onClose() {
try {
await writer.close();
} catch (error) {
console.error("Error closing SSE connection:", error);
}
}
onClose().catch(console.error);
});
return new Response(readable, { headers: {
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Content-Type": "text/event-stream",
...corsHeaders(request, options.corsOptions)
} });
}
if (request.method === "POST" && messagePattern.test(url)) {
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) return new Response(`Missing sessionId. Expected POST to ${basePath} to initiate new one`, { status: 400 });
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("application/json")) return new Response(`Unsupported content-type: ${contentType}`, { status: 400 });
const contentLength = Number.parseInt(request.headers.get("content-length") || "0", 10);
if (contentLength > MAXIMUM_MESSAGE_SIZE_BYTES) return new Response(`Request body too large: ${contentLength} bytes`, { status: 400 });
const agent = await getAgentByName(namespace, `sse:${sessionId}`, {
props: ctx.props,
jurisdiction: options.jurisdiction
});
const messageBody = await request.json();
const extraInfo = { requestInfo: { headers: Object.fromEntries(request.headers.entries()) } };
const error = await agent.onSSEMcpMessage(sessionId, messageBody, extraInfo);
if (error) return new Response(error.message, {
headers: {
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Content-Type": "text/event-stream",
...corsHeaders(request, options.corsOptions)
},
status: 400
});
return new Response("Accepted", {
headers: {
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Content-Type": "text/event-stream",
...corsHeaders(request, options.corsOptions)
},
status: 202
});
}
return new Response("Not Found", { status: 404 });
};
};
/**
* Auto-negotiating handler that serves both streamable HTTP and legacy SSE
* on the same path. Streamable-HTTP-capable clients are preferred; legacy SSE
* clients fall back transparently.
*
* Discrimination rules:
* - POST to `{basePath}/message` → legacy SSE (the sub-path is SSE-only)
* - POST to `{basePath}` → streamable HTTP
* - GET with `mcp-session-id` header → streamable HTTP (standalone SSE reconnect)
* - GET without `mcp-session-id` → legacy SSE (new SSE connection)
* - DELETE → streamable HTTP (SSE has no session teardown)
*/
const createAutoHandler = (basePath, namespace, options = {}) => {
const handleStreamableHttp = createStreamingHttpHandler(basePath, namespace, options);
const handleLegacySse = createLegacySseHandler(basePath, namespace, options);
const messagePattern = new URLPattern({ pathname: `${basePath}/message` });
return async (request, ctx) => {
const url = new URL(request.url);
if (request.method === "DELETE") return handleStreamableHttp(request, ctx);
if (request.method === "POST" && messagePattern.test(url)) return handleLegacySse(request, ctx);
if (request.method === "POST") return handleStreamableHttp(request, ctx);
if (request.method === "GET" && request.headers.has("mcp-session-id")) return handleStreamableHttp(request, ctx);
if (request.method === "GET") return handleLegacySse(request, ctx);
return new Response("Method Not Allowed", {
status: 405,
headers: {
Allow: "GET, POST, DELETE",
...corsHeaders(request, options.corsOptions)
}
});
};
};
function corsHeaders(_request, corsOptions = {}) {
const origin = corsOptions.origin || "*";
return {
"Access-Control-Allow-Headers": corsOptions.headers || "Content-Type, Accept, Authorization, mcp-session-id, mcp-protocol-version",
"Access-Control-Allow-Methods": corsOptions.methods || "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Origin": origin,
"Access-Control-Expose-Headers": corsOptions.exposeHeaders || "mcp-session-id",
"Access-Control-Max-Age": (corsOptions.maxAge || 86400).toString()
};
}
function handleCORS(request, corsOptions) {
if (request.method === "OPTIONS") return new Response(null, { headers: corsHeaders(request, corsOptions) });
return null;
}
function isDurableObjectNamespace(namespace) {
return typeof namespace === "object" && namespace !== null && "newUniqueId" in namespace && typeof namespace.newUniqueId === "function" && "idFromName" in namespace && typeof namespace.idFromName === "function";
}
//#endregion
//#region src/mcp/transport.ts
var McpSSETransport = class {
constructor() {
this._started = false;
const { agent } = getCurrentAgent();
if (!agent) throw new Error("McpAgent was not found in Transport constructor");
this.sessionId = agent.getSessionId();
this._getWebSocket = () => agent.getWebSocket();
}
async start() {
if (this._started) throw new Error("Transport already started");
this._started = true;
}
async send(message) {
if (!this._started) throw new Error("Transport not started");
const websocket = this._getWebSocket();
if (!websocket) throw new Error("WebSocket not connected");
try {
websocket.send(JSON.stringify(message));
} catch (error) {
this.onerror?.(error);
}
}
async close() {
this.onclose?.();
}
};
function isClearableEventStore(store) {
return typeof store.clearStream === "function";
}
/**
* Adapted from: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/client/streamableHttp.ts
* - Validation and initialization are removed as they're handled in `McpAgent.serve()` handler.
* - Replaces the Node-style `req`/`res` with Worker's `Request`.
* - Writes events as WS messages that the Worker forwards to the client as SSE events.
* - Replaces the in-memory maps that track requestID/stream by using `connection.setState()` and `agent.getConnections()`.
*
* Besides these points, the implementation is the same and should be updated to match the original as new features are added.
*/
/** Fixed streamId for the standalone GET listen stream. */
const STANDALONE_STREAM_ID = "_GET_stream";
var StreamableHTTPServerTransport = class {
constructor(options) {
this._started = false;
this._streamResponseIds = /* @__PURE__ */ new Map();
const { agent } = getCurrentAgent();
if (!agent) throw new Error("McpAgent was not found in Transport constructor");
this._agent = agent;
this.sessionId = agent.getSessionId();
this._eventStore = options.eventStore;
}
/**
* Starts the transport. This is required by the Transport interface but is a no-op
* for the Streamable HTTP transport as connections are managed per-request.
*/
async start() {
if (this._started) throw new Error("Transport already started");
this._started = true;
}
/**
* Handles GET requests for SSE stream.
*
* Two roles a GET can play:
* 1. Fresh standalone listen stream — carries server-initiated
* requests/notifications unrelated to any in-progress POST.
* 2. Resumption of a previously-disconnected stream via
* `Last-Event-ID`. The disconnected stream may have been the
* standalone stream OR a POST tool-call response stream; per the
* MCP 2025-03-26 spec the server replays missed messages "on the
* stream that was disconnected" and continues delivering
* subsequent messages on that same stream.
*
* To resume a POST stream we recover the original streamId from the
* event-store and the original `requestIds` from durable storage,
* then write them onto the new WS connection so `send()` keeps
* routing in-flight tool responses to it.
*/
async handleGetRequest(req) {
const { connection, agent } = getCurrentAgent();
if (!connection) throw new Error("Connection was not found in handleGetRequest");
if (!agent) throw new Error("Agent was not found in handleGetRequest");
const lastEventId = req.headers.get("last-event-id");
if (this._eventStore && lastEventId) {
const resumedStreamId = await this._eventStore.getStreamIdForEventId?.(lastEventId);
if (resumedStreamId) {
const resumeState = { streamId: resumedStreamId };
if (resumedStreamId === STANDALONE_STREAM_ID) resumeState._standaloneSse = true;
else {
const persistedReqs = await agent.getStreamRequestIds(resumedStreamId);
if (persistedReqs && persistedReqs.length > 0) resumeState.requestIds = persistedReqs;
}
this.supersedePriorStreamConnections(agent, connection.id, resumedStreamId);
connection.setState(resumeState);
await this.replayEvents(lastEventId);
return;
}
}
this.supersedePriorStreamConnections(agent, connection.id, STANDALONE_STREAM_ID);
const standaloneState = {
streamId: STANDALONE_STREAM_ID,
_standaloneSse: true
};
connection.setState(standaloneState);
}
/**
* Close any connection (other than `selfId`) currently bound to
* `streamId`, so at most one live connection serves a given stream.
* Closing rather than mutating sibling state mirrors how the SDK's
* single `_streamMapping` entry gives last-writer-wins for free, and
* keeps `send()` from routing to a stale bridge.
*/
supersedePriorStreamConnections(agent, selfId, streamId) {
for (const other of agent.getConnections()) {
if (other.id === selfId) continue;
if (other.state?.streamId !== streamId) continue;
other.close(1e3, "Superseded by resumed stream");
}
}
/**
* Replays events that would have been sent after the specified event ID
* Only used when resumability is enabled
*/
async replayEvents(lastEventId) {
if (!this._eventStore) return;
const { connection } = getCurrentAgent();
if (!connection) throw new Error("Connection was not available in replayEvents");
try {
await this._eventStore?.replayEventsAfter(lastEventId, { send: async (eventId, message) => {
try {
this.writeSSEEvent(connection, message, eventId);
} catch (error) {
this.onerror?.(error);
}
} });
} catch (error) {
this.onerror?.(error);
}
}
/**
* Writes an event to the SSE stream with proper formatting
*/
writeSSEEvent(connection, message, eventId, close) {
let eventData = "event: message\n";
if (eventId) eventData += `id: ${eventId}\n`;
eventData += `data: ${JSON.stringify(message)}\n\n`;
return connection.send(JSON.stringify({
type: "cf_mcp_agent_event",
event: eventData,
close
}));
}
/**
* Handles POST requests containing JSON-RPC messages
*/
async handlePostRequest(req, parsedBody) {
const authInfo = req.auth;
const requestInfo = {
headers: Object.fromEntries(req.headers.entries()),
url: new URL(req.url)
};
delete requestInfo.headers[MCP_HTTP_METHOD_HEADER];
delete requestInfo.headers[MCP_MESSAGE_HEADER];
delete requestInfo.headers.upgrade;
const rawMessage = parsedBody;
let messages;
if (Array.isArray(rawMessage)) messages = rawMessage.map((msg) => JSONRPCMessageSchema.parse(msg));
else messages = [JSONRPCMessageSchema.parse(rawMessage)];
const hasRequests = messages.some(isJSONRPCRequest);
if (!hasRequests) for (const message of messages) {
if (this.messageInterceptor) {
if (await this.messageInterceptor(message, {
authInfo,
requestInfo
})) continue;
}
this.onmessage?.(message, {
authInfo,
requestInfo
});
}
else if (hasRequests) {
const { connection, agent } = getCurrentAgent();
if (!connection) throw new Error("Connection was not found in handlePostRequest");
if (!agent) throw new Error("Agent was not found in handlePostRequest");
const requestIds = messages.filter(isJSONRPCRequest).map((message) => message.id);
const streamId = connection.id;
const postState = {
streamId,
requestIds
};
connection.setState(postState);
if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds);
for (const message of messages) {
if (this.messageInterceptor) {
if (await this.messageInterceptor(message, {
authInfo,
requestInfo
})) continue;
}
this.onmessage?.(message, {
authInfo,
requestInfo
});
}
}
}
async close() {
const agent = this._agent;
for (const conn of agent.getConnections()) conn.close(1e3, "Session closed");
this.onclose?.();
}
/**
* Store the event, decide whether this is the final response, write
* the SSE frame iff a live connection is attached, then run cleanup.
* Caller resolves `streamId` and `relatedIds` (from connection state
* or persisted reverse lookup) and passes `liveConnection` as null
* when the originating WS has dropped.
*/
async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) {
const eventId = await this._eventStore?.storeEvent(streamId, message);
let shouldClose = false;
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
let responseIds = this._streamResponseIds.get(streamId);
if (!responseIds) {
responseIds = /* @__PURE__ */ new Set();
this._streamResponseIds.set(streamId, responseIds);
}
responseIds.add(requestId);
shouldClose = relatedIds.every((id) => responseIds.has(id));
if (shouldClose) this._streamResponseIds.delete(streamId);
}
if (liveConnection) try {
this.writeSSEEvent(liveConnection, message, eventId, shouldClose);
} catch (error) {
this.onerror?.(error);
}
if (shouldClose) {
await agent.deleteStreamRequestIds(streamId);
if (this._eventStore && isClearableEventStore(this._eventStore)) await this._eventStore.clearStream(streamId);
}
}
async send(message, options) {
const isResponse = isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message);
const requestId = isResponse ? message.id : options?.relatedRequestId;
if (requestId === void 0) {
if (isResponse) throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request");
return this.sendStandalone(message);
}
return this.sendForRequest(message, requestId);
}
/**
* Server-initiated message on the standalone GET stream. Stored under
* a fixed streamId so it's replayable even when no live connection is
* currently attached.
*
* Sent on exactly one stream, per MCP: "the server MUST send each of
* its JSON-RPC messages on only one of the connected streams; it MUST
* NOT broadcast the same message across multiple streams."
* `handleGetRequest` supersedes prior standalone connections, so
* there is at most one to send on.
*/
async sendStandalone(message) {
const agent = this._agent;
const eventId = await this._eventStore?.storeEvent(STANDALONE_STREAM_ID, message);
const standalone = Array.from(agent.getConnections()).find((conn) => conn.state?._standaloneSse);
if (standalone) this.writeSSEEvent(standalone, message, eventId);
}
/**
* Message scoped to a specific in-flight client request: a tool
* response, error, or progress notification. Resolves which stream
* owns the request id (live POST connection, resumed GET, or
* persisted reverse lookup for a dropped WS) and delegates to
* {@link sendOnStream} for the actual store / write / cleanup.
*/
async sendForRequest(message, requestId) {
const agent = this._agent;
const context = getCurrentAgent();
const originatingConnection = context.agent === agent ? context.connection : void 0;
const matchingConnections = Array.from(agent.getConnections()).filter((conn) => conn.state?.requestIds?.includes(requestId));
const liveConnection = matchingConnections.find((conn) => conn.id === originatingConnection?.id) ?? (matchingConnections.length === 1 ? matchingConnections[0] : null);
if (!liveConnection && matchingConnections.length > 1) {
const routingError = {
jsonrpc: "2.0",
id: requestId,
error: {
code: -32603,
message: "Internal error"
}
};
await Promise.all(matchingConnections.map((candidate) => this.sendOnStream(agent, candidate.state?.streamId ?? candidate.id, candidate.state?.requestIds ?? [], candidate, routingError, requestId)));
return;
}
let streamId = liveConnection?.state?.streamId;
let relatedIds = liveConnection?.state?.requestIds;
if (!streamId) {
const stored = await agent.getStreamForRequestId(requestId);
if (!stored) throw new Error(`No active stream found for request ID: ${String(requestId)}`);
streamId = stored.streamId;
relatedIds = stored.requestIds;
}
await this.sendOnStream(agent, streamId, relatedIds ?? [], liveConnection, message, requestId);
}
};
//#endregion
//#region src/mcp/event-store.ts
/**
* Durable Object–backed {@link EventStore} for SSE resumability.
*
* Default for `McpAgent`. Override `McpAgent.getEventStore()` to swap
* or disable.
*
* ## Storage layout
*
* Events are stored under `__mcp_event__:<streamId>:<seqHex>`, where
* `<seqHex>` is a 16-char zero-padded counter so events in a stream
* sort lexicographically and `getStreamIdForEventId` can recover the
* stream from `eventId` without a storage hit.
*
* ## Lifecycle
*
* Each POST tool-call stream's events live only until the final
* response is delivered. The transport calls {@link clearStream}
* immediately after writing the close frame, so storage growth is
* bounded by the in-flight POST streams plus the standalone GET
* stream. There is no background sweep — quiescent agents do no work,
* and the DO itself dies with the session.
*
* Standalone GET stream events (`_GET_stream`) are *not* cleared
* automatically; they accumulate for the lifetime of the DO. Bounded
* by session length in practice.
*
* Trade-off: if the client TCP connection dies *after* the close
* frame has been enqueued on the WS but before the bytes reach the
* client, the final message is unreplayable. Every earlier event in
* the stream is still replayable while the in-flight stream is open.
*
* ## Stream id constraints
*
* `streamId` MUST NOT contain `:`. `storeEvent` asserts this so
* embedders using custom stream ids fail loudly rather than risk
* prefix-scan collisions (e.g. clearing `a` accidentally hitting
* `a:b`). Default ids (`connection.id` UUIDs and the literal
* `_GET_stream`) already satisfy this.
*/
var DurableObjectEventStore = class DurableObjectEventStore {
constructor(storage) {
this.seqByStream = /* @__PURE__ */ new Map();
this.seqInit = /* @__PURE__ */ new Map();
this.storage = storage;
}
async storeEvent(streamId, message) {
if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`);
await this.ensureSeqLoaded(streamId);
const seq = (this.seqByStream.get(streamId) ?? 0) + 1;
this.seqByStream.set(streamId, seq);
const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`;
const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`;
await this.storage.put(eventKey, message);
return eventId;
}
async getStreamIdForEventId(eventId) {
const idx = eventId.lastIndexOf(":");
return idx > 0 ? eventId.slice(0, idx) : void 0;
}
async replayEventsAfter(lastEventId, { send }) {
const streamId = await this.getStreamIdForEventId(lastEventId);
if (!streamId) return "";
const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`;
const startKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${lastEventId}\x00`;
const rows = await this.storage.list({
prefix,
start: startKey,
limit: DurableObjectEventStore.REPLAY_LIMIT
});
for (const [key, message] of rows) await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message);
return streamId;
}
/**
* Drop the event log for a single stream. Called by the transport
* immediately after a POST's final response has been written to the
* wire — no future `Last-Event-ID` for this stream is expected to
* resolve.
*
* Lists and deletes in chunks of {@link DELETE_CHUNK} (128, the DO
* storage cap) so we never load the entire event log into memory.
* After deleting, the next `list` call won't see the deleted keys,
* so passing `start: <prefix>` again is enough — no cursor bookkeeping.
*/
async clearStream(streamId) {
const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`;
for (;;) {
const rows = await this.storage.list({
prefix,
limit: DurableObjectEventStore.DELETE_CHUNK
});
if (rows.size === 0) break;
await this.storage.delete([...rows.keys()]);
}
this.seqByStream.delete(streamId);
this.seqInit.delete(streamId);
}
async ensureSeqLoaded(streamId) {
if (this.seqByStream.has(streamId)) return;
let pending = this.seqInit.get(streamId);
if (!pending) {
pending = (async () => {
const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`;
const rows = await this.storage.list({
prefix,
reverse: true,
limit: 1
});
let seq = 0;
for (const key of rows.keys()) {
const parsed = Number.parseInt(key.slice(prefix.length), 16);
if (Number.isFinite(parsed)) seq = parsed;
}
if (!this.seqByStream.has(streamId)) this.seqByStream.set(streamId, seq);
})();
this.seqInit.set(streamId, pending);
}
try {
await pending;
} finally {
this.seqInit.delete(streamId);
}
}
};
DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";
DurableObjectEventStore.SEQ_PAD = 16;
DurableObjectEventStore.DELETE_CHUNK = 128;
DurableObjectEventStore.REPLAY_LIMIT = 1e3;
//#endregion
//#region src/mcp/client-transports.ts
/**
* Deprecated transport wrappers
*/
let didWarnAboutSSEEdgeClientTransport = false;
/**
* @deprecated Use SSEClientTransport from @modelcontextprotocol/client instead. This alias will be removed in the next major version.
*/
var SSEEdgeClientTransport = class extends SSEClientTransport {
constructor(url, options) {
super(url, options);
if (!didWarnAboutSSEEdgeClientTransport) {
didWarnAboutSSEEdgeClientTransport = true;
console.warn("SSEEdgeClientTransport is deprecated. Use SSEClientTransport from @modelcontextprotocol/client instead. SSEEdgeClientTransport will be removed in the next major version.");
}
}
};
let didWarnAboutStreamableHTTPEdgeClientTransport = false;
/**
* @deprecated Use StreamableHTTPClientTransport from @modelcontextprotocol/client instead. This alias will be removed in the next major version.
*/
var StreamableHTTPEdgeClientTransport = class extends StreamableHTTPClientTransport {
constructor(url, options) {
super(url, options);
if (!didWarnAboutStreamableHTTPEdgeClientTransport) {
didWarnAboutStreamableHTTPEdgeClientTransport = true;
console.warn("StreamableHTTPEdgeClientTransport is deprecated. Use StreamableHTTPClientTransport from @modelcontextprotocol/client instead. StreamableHTTPEdgeClientTransport will be removed in the next major version.");
}
}
};
//#endregion
//#region src/mcp/worker-transport.ts
/**
* WorkerTransport — retained sessionful Legacy transport
*
* Thin Cloudflare-Workers wrapper around the official MCP SDK v1
* `WebStandardStreamableHTTPServerTransport`. The wrapper layers a couple of
* Workers-specific concerns on top of the SDK transport without forking it:
*
* 1. **CORS** — preflight handling and response-header injection,
* configurable via `corsOptions`.
* 2. **Persistent transport state** — when a `storage` adapter
* (`MCPStorageApi`) is supplied, the wrapper persists
* `{sessionId, initialized, initializeParams}` so that an MCP session can
* survive DO hibernation / eviction. On the first request after a cold
* start, the saved initialize params are replayed through the `Server`
* so client capabilities are re-established.
* 3. **SSE keepalive** — delegated to the SDK transport, which writes SSE
* comment frames and owns timer cleanup. Configure the cadence with the
* inherited `keepAliveMs` option.
*
* Stateless handlers do not import this module. Everything else (session
* validation, SSE streaming, protocol-version
* negotiation, event-store resumability, etc.) is delegated to the SDK
* transport.
*/
/** Sentinel id used when replaying the persisted initialize request. */
const RESTORE_REQUEST_ID = "__worker_transport_restore__";
const DEFAULT_CORS_OPTIONS = {
origin: "*",
headers: "Content-Type, Accept, Authorization, mcp-session-id, MCP-Protocol-Version",
methods: "GET, POST, DELETE, OPTIONS",
exposeHeaders: "mcp-session-id",
maxAge: 86400
};
var WorkerTransport = class extends WebStandardStreamableHTTPServerTransport$1 {
constructor(options = {}) {
const { corsOptions, storage, onsessioninitialized, ...sdkOptions } = options;
super({
...sdkOptions,
onsessioninitialized: void 0
});
this._stateRestored = false;
this._bridgeInstalled = false;
this._closedRequestIds = /* @__PURE__ */ new Set();
this._corsOptions = corsOptions;
this._storage = storage;
this._userOnSessionInitialized = onsessioninitialized;
}
/**
* Backwards-compatible alias for the SDK's internal `_started` flag.
* Several callers and tests check `transport.started` directly.
*/
get started() {
return this._started;
}
/**
* Top-level request entry point. Handles CORS preflight, restores any
* persisted state on first invocation, then delegates to the SDK transport
* and finally appends CORS headers to whatever response comes back.
*/
async handleRequest(request, options) {
if (request.method === "OPTIONS") return new Response(null, { headers: this.getCorsHeaders({ forPreflight: true }) });
await this.restoreState();
this.installOnSessionInitializedBridge();
await this.captureInitializeParams(request, options);
const response = await super.handleRequest(request, options);
return this.withCorsHeaders(this.normalizeAllowHeader(response));
}
/**
* The SDK's 405 responses advertise `Allow: GET, POST, DELETE` because
* OPTIONS is handled outside the SDK. Since our wrapper *does* handle
* OPTIONS, advertise it in `Allow` so clients can probe accurately.
*/
normalizeAllowHeader(response) {
if (response.status !== 405) return response;
const allow = response.headers.get("Allow");
if (!allow || allow.includes("OPTIONS")) return response;
const headers = new Headers(response.headers);
headers.set("Allow", `${allow}, OPTIONS`);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}
closeSSEStream(requestId) {
this._closedRequestIds.add(requestId);
super.closeSSEStream(requestId);
}
async close() {
this._closedRequestIds.clear();
await super.close();
}
/**
* Swallow two classes of message that would otherwise surface as
* unhandled rejections from the SDK transport's `send()`:
*
* 1. Replayed initialize responses (the `RESTORE_REQUEST_ID` sentinel)
* — we synthesise these in `restoreState()` to rebuild server
* capabilities; there's no real client waiting for the response.
* 2. Sends for a request id whose SSE stream has been deliberately
* closed via `closeSSEStream`. The protocol layer's tool-handler
* promise may settle after the close, and the SDK's `send()` throws
* "No connection established" — a race the pre-refactor transport
* silently swallowed.
*
* Everything else is delegated. We use `await super.send(...)` rather
* than `return super.send(...)` so any rejection is observed inside this
* async frame; without the await, the test runner's
* unhandled-rejection tracker can fire before the caller's own `await`
* observes it.
*/
async send(message, options) {
let requestId = options?.relatedRequestId;
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) requestId = message.id;
if (requestId === RESTORE_REQUEST_ID) return;
if (requestId !== void 0 && this._closedRequestIds.has(requestId)) return;
await super.send(message, options);
}
getCorsHeaders({ forPreflight } = {}) {
const merged = {
...DEFAULT_CORS_OPTIONS,
...this._corsOptions
};
if (forPreflight) return {
"Access-Control-Allow-Origin": merged.origin,
"Access-Control-Allow-Headers": merged.headers,
"Access-Control-Allow-Methods": merged.methods,
"Access-Control-Max-Age": String(merged.maxAge)
};
return {
"Access-Control-Allow-Origin": merged.origin,
"Access-Control-Expose-Headers": merged.exposeHeaders
};
}
withCorsHeaders(response) {
const headers = new Headers(response.headers);
for (const [k, v] of Object.entries(this.getCorsHeaders())) headers.set(k, v);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}
installOnSessionInitializedBridge() {
if (this._bridgeInstalled) return;
const sdk = this;
sdk._onsessioninitialized = async (sessionId) => {
if (this._userOnSessionInitialized) await Promise.resolve(this._userOnSessionInitialized(sessionId));
await this.saveState();
};
this._bridgeInstalled = true;
}
async captureInitializeParams(request, handleOptions) {
if (request.method !== "POST") return;
try {
const parsed = handleOptions?.parsedBody ?? await request.clone().json();
const init = (Array.isArray(parsed) ? parsed : [parsed]).find((m) => typeof m === "object" && m !== null && isInitializeRequest(m));
if (init && isInitializeRequest(init)) this._capturedInitializeParams = {
capabilities: init.params.capabilities,
clientInfo: init.params.clientInfo,
protocolVersion: init.params.protocolVersion
};
} catch {}
}
async restoreState() {
if (!this._storage || this._stateRestored) return;
this._stateRestored = true;
let state;
try {
state = await Promise.resolve(this._storage.get());
} catch (error) {
this._stateRestored = false;
throw error;
}
if (!state) return;
const sdk = this;
sdk.sessionId = state.sessionId;
sdk._initialized = state.initialized;
this._capturedInitializeParams = state.initializeParams;
if (state.initializeParams && this.onmessage) this.onmessage({
jsonrpc: "2.0",
id: RESTORE_REQUEST_ID,
method: "initialize",
params: state.initializeParams
});
}
async saveState() {
if (!this._storage) return;
const sdk = this;
const state = {
sessionId: sdk.sessionId,
initialized: sdk._initialized,
initializeParams: this._capturedInitializeParams
};
await Promise.resolve(this._storage.set(state));
}
};
//#endregion
//#region src/mcp/handler-legacy.ts
/**
* Create a sessionful Legacy MCP handler backed by SDK v1.
*
* New Stateless servers should use `createMcpHandler` from
* `agents/mcp/server` instead.
*/
function createLegacyMcpHandler(server, options = {}) {
const route = options.route ?? "/mcp";
const { route: _route, authContext, transport: providedTransport, ...transportOptions } = options;
return async (request, _env, ctx) => {
const url = new URL(request.url);
if (route && url.pathname !== route) return new Response("Not Found", { status: 404 });
const transport = providedTransport ?? new WorkerTransport(transportOptions);
const resolvedAuthContext = authContext ?? (ctx.props && Object.keys(ctx.props).length > 0 ? { props: ctx.props } : void 0);
if (!transport.started) {
if (server instanceof McpServer$1 ? server.isConnected() : server.transport !== void 0) throw new Error("Server is already connected to a transport. Create a new McpServer instance per request for stateless handlers.");
await server.connect(transport);
}
const handleRequest = () => transport.handleRequest(request);
try {
return resolvedAuthContext ? await runWithAuthContext(resolvedAuthContext, handleRequest) : await handleRequest();
} catch (error) {
console.error("MCP handler error:", error);
return Response.json({
jsonrpc: "2.0",
error: {
code: -32603,
message: error instanceof Error ? error.message : "Internal server error"
},
id: null
}, { status: 500 });
}
};
}
//#endregion
//#region src/mcp/handler-warning.ts
let didWarnAboutLegacyCreateMcpHandlerOverload = false;
function warnLegacyCreateMcpHandlerOverload() {
if (didWarnAboutLegacyCreateMcpHandlerOverload) return;
didWarnAboutLegacyCreateMcpHandlerOverload = true;
console.warn("[agents/mcp] Passing an MCP SDK v1 server to createMcpHandler is deprecated and will be removed in the next major version. Pass an @modelcontextprotocol/server factory to createMcpHandler. To temporarily retain sessionful SDK v1 behavior while migrating, use createLegacyMcpHandler.");
}
//#endregion
//#region src/mcp/handler-compat.ts
function createMcpHandler(serverOrFactory, options = {}) {
if (typeof serverOrFactory === "function") return createStatelessMcpHandler(serverOrFactory, options);
if (serverOrFactory instanceof McpServer$1 || serverOrFactory instanceof Server$1) {
warnLegacyCreateMcpHandlerOverload();
return createLegacyMcpHandler(serverOrFactory, options);
}
throw new TypeError("createMcpHandler received an unsupported server. Pass a factory returning McpServer or Server from \"@modelcontextprotocol/server\", or use createLegacyMcpHandler with an MCP SDK v1 server.");
}
let didWarnAboutExperimentalCreateMcpHandler = false;
/**
* @deprecated Pass an SDK v2 factory to createMcpHandler.
* experimental_createMcpHandler will be removed in the next major version.
* Use createLegacyMcpHandler only to temporarily retain sessionful SDK v1
* behavior while migrating.
*/
function experimental_createMcpHandler(server, options = {}) {
if (!didWarnAboutExperimentalCreateMcpHandler) {
didWarnAboutExperimentalCreateMcpHandler = true;
console.warn("experimental_createMcpHandler is deprecated and will be removed in the next major version. Pass an @modelcontextprotocol/server factory to createMcpHandler. To temporarily retain sessionful SDK v1 behavior while migrating, use createLegacyMcpHandler.");
}
return createLegacyMcpHandler(server, options);
}
//#endregion
//#region src/mcp/legacy-agent.ts
/**
* @deprecated McpAgent is feature-frozen. Migrate to an SDK v2 factory with
* createMcpHandler from agents/mcp/server. When sessionful features prevent an
* immediate migration, run the stateless route beside the existing McpAgent
* route until clients transition and sessions drain.
*/
var McpAgent = class McpAgent extends Agent {
constructor(..._args) {
super(..._args);
this._pendingElicitations = /* @__PURE__ */ new Map();
}
shouldSendProtocolMessages(_connection, ctx) {
return !ctx.request.headers.get(MCP_HTTP_METHOD_HEADER);
}
async setInitializeRequest(initializeRequest) {
await this.ctx.storage.put("initializeRequest", initializeRequest);
}
async getInitializeRequest() {
return this.ctx.storage.get("initializeRequest");
}
/** Persist the `requestIds` for a POST stream. @internal */
async setStreamRequestIds(streamId, requestIds) {
await this.ctx.storage.put(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`, requestIds);
}
/** Read the persisted `requestIds` for a POST stream. @internal */
async getStreamRequestIds(streamId) {
return this.ctx.storage.get(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
}
/** Drop the persisted `requestIds` for a POST stream. @internal */
async deleteStreamRequestIds(streamId) {
await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
}
/**
* Reverse lookup: find which POST stream a given `requestId` belongs
* to, and return the stream's full `requestIds` list in the same
* pass. Used by the transport when the originating WS has dropped,
* so `send()` can still record events for replay and decide whether
* the stream is fully responded — mirrors the SDK's
* `_requestToStreamMapping` which outlives connection loss.
*
* Returning `requestIds` alongside `streamId` lets `send()` skip a
* second `getStreamRequestIds` read on the same key.
*
* O(n) in the number of in-flight POST streams — single-digit in
* practice since each stream is cleaned up on its final response.
* The `limit` is a defensive ceiling so an abandoned-POST leak can't
* unbounded-load this scan; if you hit it, something else has gone
* wrong and `send()` will throw `No active stream found`.
*
* @internal
*/
async getStreamForRequestId(requestId) {
const STREAM_REQS_SCAN_LIMIT = 1e3;
const rows = await this.ctx.storage.list({
prefix: McpAgent.STREAM_REQS_KEY_PREFIX,
limit: STREAM_REQS_SCAN_LIMIT
});
if (rows.size === STREAM_REQS_SCAN_LIMIT) console.warn(`McpAgent: getStreamForRequestId hit the ${STREAM_REQS_SCAN_LIMIT}-key scan cap; stale __mcp_stream_reqs__ entries may be accumulating from abandoned POSTs`);
for (const [key, requestIds] of rows) if (requestIds?.includes(requestId)) return {
streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
requestIds
};
}
/** Read the transport type for this agent.
* This relies on the naming scheme being `sse:${sessionId}`,
* `streamable-http:${sessionId}`, or `rpc:${sessionId}`.
*/
getTransportType() {
const [t, ..._] = this.name.split(":");
switch (t) {
case "sse": return "sse";
case "streamable-http": return "streamable-http";
case "rpc": return "rpc";
default: throw new Error("Invalid transport type. McpAgent must be addressed with a valid protocol.");
}
}
/** Read the sessionId for this agent.
* This relies on the naming scheme being `sse:${sessionId}`
* or `streamable-http:${sessionId}`.
*/
getSessionId() {
const [_, sessionId] = this.name.split(":");
if (!sessionId) throw new Error("Invalid session id. McpAgent must be addressed with a valid session id.");
return sessionId;
}
/** Get the unique WebSocket. SSE transport only. */
getWebSocket() {
const websockets = Array.from(this.getConnections());
if (websockets.length === 0) return null;
return websockets[0];
}
/**
* Returns options for configuring the RPC server transport.
* Override this method to customize RPC transport behavior (e.g., timeout).
*
* @example
* ```typescript
* class MyMCP extends McpAgent {
* protected getRpcTransportOptions() {
* return { timeout: 120000 }; // 2 minutes
* }
* }
* ```
*/
getRpcTransportOptions() {
return {};
}
/**
* Returns the {@link EventStore} for SSE resumability. Defaults to a
* {@link DurableObjectEventStore} backed by this agent's storage,
* letting clients reconnect with `Last-Event-ID` after the Cloudflare
* edge closes an idle SSE stream (~5 minute watchdog) instead of
* relying on a server-side keepalive that would block hibernation.
*
* Per-stream events are cleared by the transport immediately after
* the final response is written to the wire, so there's no
* background cleanup — storage cost is bounded by the in-flight
* streams alone.
*
* Override to disable (`return undefined`) or swap implementations.
*/
getEventStore() {
return new DurableObjectEventStore(this.ctx.storage);
}
/** Returns a new transport matching the type of the Agent. */
initTransport() {
switch (this.getTransportType()) {
case "sse": return new McpSSETransport();
case "streamable-http": {
const transport = new StreamableHTTPServerTransport({ eventStore: this.getEventStore() });
transport.messageInterceptor = (message) => {
return Promise.resolve(this._handleElicitationResponse(message));
};
return transport;
}
case "rpc": return new RPCServerTransport(this.getRpcTransportOptions());
}
}
/** Update and store the props */
async updateProps(props) {
await this.ctx.storage.put("props", props ?? {});
this.props = props;
}
async reinitializeServer() {
const initializeRequest = await this.getInitializeRequest();
if (initializeRequest) this._transport?.onmessage?.(initializeRequest);
}
/** Sets up the MCP transport and server every time the Agent is started.*/
async onStart(props) {
if (props) await this.updateProps(props);
else this.props = await this.ctx.storage.get("props");
await this.init();
const server = await this.server;
if (server instanceof McpServer || server instanceof Server) throw new TypeError("McpAgent uses MCP SDK v1 and cannot serve McpServer from \"@modelcontextprotocol/server\". For MCP SDK v2, use createMcpHandler. Existing McpAgent applications should continue importing McpServer from \"@modelcontextprotocol/sdk/server/mcp.js\".");
this._transport = this.initTransport();
if (!this._transport) throw new Error("Failed to initialize transport");
await server.connect(this._transport);
await this.reinitializeServer();
}
/** Validates new WebSocket connections. */
async onConnect(conn, { request: req }) {
switch (this.getTransportType()) {
case "sse":
if (Array.from(this.getConnections()).length > 1) {
conn.close(1008, "Websocket already connected");
return;
}
break;
case "streamable-http": if (this._transport instanceof StreamableHTTPServerTransport) switch (req.headers.get(MCP_HTTP_METHOD_HEADER)) {
case "POST": {
const payloadHeader = req.headers.get(MCP_MESSAGE_HEADER);
let rawPayload;
if (!payloadHeader) rawPayload = "{}";
else try {
rawPayload = Buffer.from(payloadHeader, "base64").toString("utf-8");
} catch (_error) {
throw new Error("Internal Server Error: Failed to decode MCP message header");
}
const parsedBody = JSON.parse(rawPayload);
this._transport?.handlePostRequest(req, parsedBody);
break;
}
case "GET":
this._transport?.handleGetRequest(req);
break;
}
}
}
/** Handles MCP Messages for the legacy SSE transport. */
async onSSEMcpMessage(_sessionId, messageBody, extraInfo) {
if (this.getTransportType() !== "sse") return /* @__PURE__ */ new Error("Internal Server Error: Expected SSE transport");
try {
let parsedMessage;
try {
parsedMessage = JSONRPCMessageSchema.parse(messageBody);
} catch (error) {
this._transport?.onerror?.(error);
throw error;
}
if (this._handleElicitationResponse(parsedMessage)) return null;
this._transport?.onmessage?.(parsedMessage, extraInfo);
return null;
} catch (error) {
console.error("Error forwarding message to SSE:", error);
this._transport?.onerror?.(error);
return error;
}
}
/** Elicit user input with a message and schema */
async elicitInput(params, options) {
const requestId = `elicit_${Math.random().toString(36).substring(2, 11)}`;
const elicitRequest = {
jsonrpc: "2.0",
id: requestId,
method: "elicitation/create",
params: {
message: params.message,
requestedSchema: params.requestedSchema
}
};
let timeoutId;
const responsePromise = new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
this._pendingElicitations.delete(requestId);
reject(/* @__PURE__ */ new Error("Elicitation request timed out"));
}, 6e4);
this._pendingElicitations.set(requestId, {
resolve: (result) => {
clearTimeout(timeoutId);
this._pendingElicitations.delete(requestId);
resolve(result);
},
reject: (err) => {
clearTimeout(timeoutId);
this._pendingElicitations.delete(requestId);
reject(err);
}
});
});
const cleanup = () => {
clearTimeout(timeoutId);
this._pendingElicitations.delete(requestId);
};
return this.keepAliveWhile(async () => {
if (this._transport) try {
await this._transport.send(elicitRequest, options);
} catch (error) {
cleanup();
throw error;
}
else {
const connections = this.getConnections();
if (!connections || Array.from(connections).length === 0) {
cleanup();
throw new Error("No active connections available for elicitation");
}
const connectionList = Array.from(connections);
for (const connection of connectionList) try {
connection.send(JSON.stringify(elicitRequest));
} catch (error) {
console.error("Failed to send elicitation request:", error);
}
}
return responsePromise;
});
}
/** Handle elicitation responses via in-memory resolver */
_handleElicitationResponse(message) {
if (isJSONRPCResultResponse(message) && message.result) {
const requestId = message.id?.toString();
if (!requestId || !requestId.startsWith("elicit_")) return false;
const pending = this._pendingElicitations.get(requestId);
if (!pending) return false;
pending.resolve(message.result);
return true;
}
if (isJSONRPCErrorResponse(message)) {
const requestId = message.id?.toString();
if (!requestId || !requestId.startsWith("elicit_")) return false;
const pending = this._pendingElicitations.get(requestId);
if (!pending) return false;
pending.resolve({
action: "cancel",
content: { error: message.error.message || "Elicitation request failed" }
});
return true;
}
return false;
}
/**
* Handle an RPC message for MCP
* This method is called by the RPC stub to process MCP messages
* @param message The JSON-RPC message(s) to handle
* @returns The response message(s) or undefined
*/
async handleMcpMessage(message) {
await this.__unsafe_ensureInitialized();
if (!(this._transport instanceof RPCServerTransport)) throw new Error("Expected RPC transport");
const transport = this._transport;
return await this.keepAliveWhile(async () => {
if (!Array.isArray(message)) {
const parseResult = JSONRPCMessageSchema.safeParse(message);
if (parseResult.success && this._handleElicitationResponse(parseResult.data)) return await transport._awaitPendingResponse();
}
return await transport.handle(message);
});
}
/** Return a handler for the given path for this MCP.
* Defaults to Streamable HTTP transport.
*/
static serve(path, { binding = "MCP_OBJECT", corsOptions, transport = "streamable-http", jurisdiction } = {}) {
return { async fetch(request, env, ctx) {
const corsResponse = handleCORS(request, corsOptions);
if (corsResponse) return corsResponse;
const bindingValue = env[binding];
if (bindingValue == null || typeof bindingValue !== "object") throw new Error(`Could not find McpAgent binding for ${binding}. Did you update your wrangler configuration?`);
if (!isDurableObjectNamespace(bindingValue)) throw new Error(`Invalid McpAgent binding for ${binding}. Make sure it's a Durable Object binding.`);
const namespace = bindingValue;
switch (transport) {
case "streamable-http": return createStreamingHttpHandler(path, namespace, {
corsOptions,
jurisdiction
})(request, ctx);
case "sse": return createLegacySseHandler(path, namespace, {
corsOptions,
jurisdiction
})(request, ctx);
case "auto": return createAutoHandler(path, namespace, {
corsOptions,
jurisdiction
})(request, ctx);
default: return new Response("Invalid MCP transport mode. Only `streamable-http`, `sse`, or `auto` are allowed.", { status: 500 });
}
} };
}
/**
* Legacy api
**/
static mount(path, opts = {}) {
return McpAgent.serveSSE(path, opts);
}
static serveSSE(path, opts = {}) {
return McpAgent.serve(path, {
...opts,
transport: "sse"
});
}
};
McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
//#endregion
export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createLegacyMcpHandler, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
//# sourceMappingURL=index.js.map