1978 lines
80 KiB
Plaintext
1978 lines
80 KiB
Plaintext
const require_chunk = require('./chunk-Bnu9O96Y.cjs');
|
|
const require_mcp = require('./mcp-BumlNux0.cjs');
|
|
const require_src = require('./src-BhnGYybN.cjs');
|
|
let _modelcontextprotocol_server__shims = require("@modelcontextprotocol/server/_shims");
|
|
let _modelcontextprotocol_core_internal = require("@modelcontextprotocol/core/internal");
|
|
|
|
//#region src/server/perRequestTransport.ts
|
|
/**
|
|
* The per-request micro-transport: a real, connected `Transport` whose whole
|
|
* lifetime is one HTTP exchange. See the module documentation for the
|
|
* response shapes it produces.
|
|
*/
|
|
var PerRequestHTTPServerTransport = class {
|
|
onclose;
|
|
onerror;
|
|
onmessage;
|
|
_classification;
|
|
_responseMode;
|
|
_started = false;
|
|
_used = false;
|
|
_closed = false;
|
|
_terminalDelivered = false;
|
|
/**
|
|
* `true` only while the inbound message is being delivered synchronously
|
|
* to the connected protocol layer. The pre-handler gates (the era
|
|
* registry gate, the edge→instance handoff check, the missing-handler
|
|
* rejection) answer inside this window; request handlers always run
|
|
* after it (the protocol layer defers them to a microtask). An error
|
|
* sent inside the window is therefore ladder-originated, and an error
|
|
* sent after it is handler-produced.
|
|
*/
|
|
_dispatchWindowOpen = false;
|
|
_requestId;
|
|
_deferredResponse;
|
|
_sse;
|
|
_abortCleanup;
|
|
_keepAliveMs;
|
|
constructor(options) {
|
|
this._classification = options.classification;
|
|
this._responseMode = options.responseMode ?? "auto";
|
|
this._keepAliveMs = options.keepAliveMs ?? require_mcp.DEFAULT_SSE_KEEP_ALIVE_MS;
|
|
}
|
|
async start() {
|
|
if (this._started) throw new Error("PerRequestHTTPServerTransport is already started");
|
|
this._started = true;
|
|
}
|
|
/**
|
|
* Serves the single exchange: delivers the classified message to the
|
|
* connected server instance and resolves with the HTTP response.
|
|
*
|
|
* Throws when called a second time (the transport is strictly
|
|
* single-use), or before a server has been connected to the transport.
|
|
* The returned promise rejects with a connection-closed error when the
|
|
* transport is closed before a response was produced (for example because
|
|
* the client disconnected).
|
|
*/
|
|
async handleMessage(message, extra) {
|
|
if (this._used) throw new Error("PerRequestHTTPServerTransport serves exactly one exchange; construct a new transport per request");
|
|
if (!this._started || this.onmessage === void 0) throw new Error("PerRequestHTTPServerTransport is not connected: connect a server to this transport before handling a message");
|
|
if (this._closed) throw new Error("PerRequestHTTPServerTransport is closed");
|
|
this._used = true;
|
|
const signal = extra?.request?.signal;
|
|
if (signal?.aborted) {
|
|
await this.close();
|
|
throw new require_src.SdkError(require_src.SdkErrorCode.ConnectionClosed, "The request was aborted before it could be handled");
|
|
}
|
|
const messageExtra = {
|
|
classification: this._classification,
|
|
...extra?.request !== void 0 && { request: extra.request },
|
|
...extra?.authInfo !== void 0 && { authInfo: extra.authInfo }
|
|
};
|
|
if (require_src.isJSONRPCRequest(message)) {
|
|
this._requestId = message.id;
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
this._deferredResponse = {
|
|
promise,
|
|
resolve,
|
|
reject,
|
|
settled: false
|
|
};
|
|
if (signal !== void 0) {
|
|
const onAbort = () => void this.close();
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
this._abortCleanup = () => signal.removeEventListener("abort", onAbort);
|
|
}
|
|
this._dispatchWindowOpen = true;
|
|
try {
|
|
this.onmessage(message, messageExtra);
|
|
} finally {
|
|
this._dispatchWindowOpen = false;
|
|
}
|
|
if (this._responseMode === "sse" && !this._closed && !this._deferredResponse.settled) this.upgradeToSse();
|
|
return promise;
|
|
}
|
|
this.onmessage(message, messageExtra);
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
async send(message, options) {
|
|
if (this._closed) return;
|
|
const isResponse = require_src.isJSONRPCResultResponse(message) || require_src.isJSONRPCErrorResponse(message);
|
|
const relatedId = isResponse ? message.id : options?.relatedRequestId;
|
|
if (this._requestId === void 0 || relatedId === void 0 || relatedId !== this._requestId) {
|
|
if (isResponse) this.onerror?.(/* @__PURE__ */ new Error(`Received a response for an unknown request id: ${String(message.id)}`));
|
|
return;
|
|
}
|
|
if (isResponse) {
|
|
if (this._terminalDelivered) return;
|
|
this._terminalDelivered = true;
|
|
const errorCode = require_src.isJSONRPCErrorResponse(message) ? message.error.code : void 0;
|
|
const ladderStatus = errorCode !== void 0 && (this._dispatchWindowOpen || errorCode === require_src.ProtocolErrorCode.MissingRequiredClientCapability) ? require_src.LADDER_ERROR_HTTP_STATUS[errorCode] : void 0;
|
|
if (ladderStatus !== void 0 && this._sse === void 0) {
|
|
this.settleResponse(Response.json(message, {
|
|
status: ladderStatus,
|
|
headers: { "Content-Type": "application/json" }
|
|
}));
|
|
queueMicrotask(() => void this.close());
|
|
return;
|
|
}
|
|
if (this._sse !== void 0 || this._responseMode === "sse") {
|
|
if (this._sse === void 0) this.upgradeToSse();
|
|
this.writeMessageFrame(message);
|
|
this.finalizeStream();
|
|
return;
|
|
}
|
|
this.settleResponse(Response.json(message, {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
}));
|
|
queueMicrotask(() => void this.close());
|
|
return;
|
|
}
|
|
if (this._responseMode === "json") return;
|
|
if (this._sse === void 0) this.upgradeToSse();
|
|
this.writeMessageFrame(message);
|
|
}
|
|
/**
|
|
* Writes an SSE comment frame (a keep-alive heartbeat). Dropped when the
|
|
* exchange is not currently streaming.
|
|
*/
|
|
writeCommentFrame(comment) {
|
|
if (this._closed || this._sse === void 0 || this._sse.closed) return;
|
|
const frame = comment.split("\n").map((line) => `: ${line}`).join("\n");
|
|
this.writeFrame(`${frame}\n\n`);
|
|
}
|
|
async close() {
|
|
if (this._closed) return;
|
|
this._closed = true;
|
|
this._abortCleanup?.();
|
|
this._abortCleanup = void 0;
|
|
if (this._sse?.keepAliveTimer !== void 0) clearInterval(this._sse.keepAliveTimer);
|
|
if (this._sse !== void 0 && !this._sse.closed) {
|
|
this._sse.closed = true;
|
|
try {
|
|
this._sse.controller.close();
|
|
} catch {}
|
|
}
|
|
if (this._deferredResponse !== void 0 && !this._deferredResponse.settled) {
|
|
this._deferredResponse.settled = true;
|
|
this._deferredResponse.reject(new require_src.SdkError(require_src.SdkErrorCode.ConnectionClosed, "Connection closed before a response was produced"));
|
|
}
|
|
this.onclose?.();
|
|
}
|
|
settleResponse(response) {
|
|
if (this._deferredResponse === void 0 || this._deferredResponse.settled) return;
|
|
this._deferredResponse.settled = true;
|
|
this._deferredResponse.resolve(response);
|
|
}
|
|
upgradeToSse() {
|
|
let controller;
|
|
const readable = new ReadableStream({
|
|
start: (streamController) => {
|
|
controller = streamController;
|
|
},
|
|
cancel: () => {
|
|
this.close();
|
|
}
|
|
});
|
|
this._sse = {
|
|
controller,
|
|
encoder: new TextEncoder(),
|
|
closed: false
|
|
};
|
|
this._sse.keepAliveTimer = require_mcp.armSseKeepAlive(this._keepAliveMs, () => this.writeCommentFrame("keepalive"));
|
|
this.settleResponse(new Response(readable, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no"
|
|
}
|
|
}));
|
|
}
|
|
finalizeStream() {
|
|
if (this._sse?.keepAliveTimer !== void 0) clearInterval(this._sse.keepAliveTimer);
|
|
if (this._sse !== void 0 && !this._sse.closed) {
|
|
this._sse.closed = true;
|
|
try {
|
|
this._sse.controller.close();
|
|
} catch {}
|
|
}
|
|
queueMicrotask(() => void this.close());
|
|
}
|
|
writeMessageFrame(message) {
|
|
this.writeFrame(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
|
|
}
|
|
writeFrame(frame) {
|
|
if (this._sse === void 0 || this._sse.closed) return;
|
|
try {
|
|
this._sse.controller.enqueue(this._sse.encoder.encode(frame));
|
|
} catch (error) {
|
|
this.onerror?.(/* @__PURE__ */ new Error(`Failed to write to the response stream: ${error}`));
|
|
}
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/server/invoke.ts
|
|
/**
|
|
* Serves one classified inbound message on the given server instance and
|
|
* returns the HTTP response for the exchange.
|
|
*
|
|
* The instance is connected to a fresh single-exchange transport, the message
|
|
* is injected through the normal transport message path, and whatever the
|
|
* dispatch layer produces (the handler result, a protocol-level rejection, or
|
|
* streamed related messages followed by the result) is captured as the
|
|
* returned `Response`. For request exchanges, teardown rides the transport's
|
|
* close chain once the terminal response has been delivered; notification
|
|
* exchanges resolve with the 202 response immediately and do NOT run the
|
|
* close chain — the transport stays connected until the caller closes it or
|
|
* drops the per-request instance, which is the caller's choice either way.
|
|
*/
|
|
async function invoke(server, message, ctx) {
|
|
const transport = new PerRequestHTTPServerTransport({
|
|
classification: ctx.classification,
|
|
...ctx.responseMode !== void 0 && { responseMode: ctx.responseMode },
|
|
...ctx.keepAliveMs !== void 0 && { keepAliveMs: ctx.keepAliveMs }
|
|
});
|
|
await server.connect(transport);
|
|
return transport.handleMessage(message, {
|
|
...ctx.request !== void 0 && { request: ctx.request },
|
|
...ctx.authInfo !== void 0 && { authInfo: ctx.authInfo }
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/server/requestBody.ts
|
|
/** Default upper bound, in bytes, on a request body read by the HTTP entry points (4 MiB). */
|
|
const DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
|
|
/** Upper bound on the number of messages accepted in one JSON-RPC batch array. */
|
|
const MAX_BATCH_SIZE = 100;
|
|
/** The message answered with 413 for a request body over `maxBytes`. */
|
|
function requestBodyTooLargeMessage(maxBytes) {
|
|
return `Payload Too Large: Request body must not exceed ${maxBytes} bytes`;
|
|
}
|
|
/**
|
|
* Resolves a `maxRequestBodySize` option to the bound to apply: the default when
|
|
* omitted, otherwise the value itself, which must be a positive finite number of
|
|
* bytes (a `RangeError` is thrown at configuration time for anything else).
|
|
*/
|
|
function resolveMaxRequestBodySize(value) {
|
|
if (value === void 0) return DEFAULT_MAX_REQUEST_BODY_SIZE;
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new RangeError(`maxRequestBodySize must be a positive number of bytes, got ${String(value)}`);
|
|
return value;
|
|
}
|
|
/**
|
|
* Reads a request body as text, up to `maxBytes` (default
|
|
* {@linkcode DEFAULT_MAX_REQUEST_BODY_SIZE}). A declared `Content-Length` over the
|
|
* limit is refused without reading anything; otherwise the read stops as soon as
|
|
* more than the limit has arrived. Stream failures propagate.
|
|
*/
|
|
async function readRequestBody(request, maxBytes = DEFAULT_MAX_REQUEST_BODY_SIZE) {
|
|
if (Number(request.headers.get("content-length")) > maxBytes) return { tooLarge: true };
|
|
if (request.body === null) return {
|
|
tooLarge: false,
|
|
text: ""
|
|
};
|
|
const reader = request.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let received = 0;
|
|
let text = "";
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
received += value.byteLength;
|
|
if (received > maxBytes) return { tooLarge: true };
|
|
text += decoder.decode(value, { stream: true });
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
return {
|
|
tooLarge: false,
|
|
text: text + decoder.decode()
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/server/streamableHttp.ts
|
|
/**
|
|
* Server transport for Web Standards Streamable HTTP: this implements the MCP Streamable HTTP transport specification
|
|
* using Web Standard APIs (`Request`, `Response`, `ReadableStream`).
|
|
*
|
|
* This transport works on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc.
|
|
*
|
|
* In stateful mode:
|
|
* - Session ID is generated and included in response headers
|
|
* - Session ID is always included in initialization responses
|
|
* - Requests with invalid session IDs are rejected with `404 Not Found`
|
|
* - Non-initialization requests without a session ID are rejected with `400 Bad Request`
|
|
* - State is maintained in-memory (connections, message history)
|
|
*
|
|
* In stateless mode:
|
|
* - No Session ID is included in any responses
|
|
* - No session validation is performed
|
|
*
|
|
* @example Stateful setup
|
|
* ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateful"
|
|
* const server = new McpServer({ name: 'my-server', version: '1.0.0' });
|
|
*
|
|
* const transport = new WebStandardStreamableHTTPServerTransport({
|
|
* sessionIdGenerator: () => crypto.randomUUID()
|
|
* });
|
|
*
|
|
* await server.connect(transport);
|
|
* ```
|
|
*
|
|
* @example Stateless setup
|
|
* ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateless"
|
|
* const transport = new WebStandardStreamableHTTPServerTransport({
|
|
* sessionIdGenerator: undefined
|
|
* });
|
|
* ```
|
|
*
|
|
* @example Hono.js
|
|
* ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_hono"
|
|
* app.all('/mcp', async c => {
|
|
* // Stateless example: create a server and a transport per request.
|
|
* const server = new McpServer({ name: 'my-server', version: '1.0.0' });
|
|
* const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
* await server.connect(transport);
|
|
* return transport.handleRequest(c.req.raw);
|
|
* });
|
|
* ```
|
|
*
|
|
* @example Cloudflare Workers
|
|
* ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_workers"
|
|
* const worker = {
|
|
* async fetch(request: Request): Promise<Response> {
|
|
* // Stateless example: create a server and a transport per request.
|
|
* const server = new McpServer({ name: 'my-server', version: '1.0.0' });
|
|
* const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
* await server.connect(transport);
|
|
* return transport.handleRequest(request);
|
|
* }
|
|
* };
|
|
* ```
|
|
*/
|
|
var WebStandardStreamableHTTPServerTransport = class {
|
|
sessionIdGenerator;
|
|
_started = false;
|
|
_hasHandledRequest = false;
|
|
_closed = false;
|
|
_streamMapping = /* @__PURE__ */ new Map();
|
|
_requestToStreamMapping = /* @__PURE__ */ new Map();
|
|
_requestResponseMap = /* @__PURE__ */ new Map();
|
|
_initialized = false;
|
|
_enableJsonResponse = false;
|
|
_standaloneSseStreamId = "_GET_stream";
|
|
_eventStore;
|
|
_onsessioninitialized;
|
|
_onsessionclosed;
|
|
_allowedHosts;
|
|
_allowedOrigins;
|
|
_enableDnsRebindingProtection;
|
|
_retryInterval;
|
|
_supportedProtocolVersions;
|
|
_keepAliveMs;
|
|
_maxRequestBodySize;
|
|
_scopeChallengeResolver;
|
|
sessionId;
|
|
onclose;
|
|
onerror;
|
|
onmessage;
|
|
constructor(options = {}) {
|
|
this.sessionIdGenerator = options.sessionIdGenerator;
|
|
this._enableJsonResponse = options.enableJsonResponse ?? false;
|
|
this._eventStore = options.eventStore;
|
|
this._onsessioninitialized = options.onsessioninitialized;
|
|
this._onsessionclosed = options.onsessionclosed;
|
|
this._allowedHosts = options.allowedHosts;
|
|
this._allowedOrigins = options.allowedOrigins;
|
|
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
|
|
this._retryInterval = options.retryInterval;
|
|
this._supportedProtocolVersions = options.supportedProtocolVersions ?? _modelcontextprotocol_core_internal.SUPPORTED_PROTOCOL_VERSIONS;
|
|
this._keepAliveMs = options.keepAliveMs ?? require_mcp.DEFAULT_SSE_KEEP_ALIVE_MS;
|
|
this._maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
|
|
}
|
|
startKeepAlive(controller, encoder) {
|
|
if (this._closed) return void 0;
|
|
const timer = require_mcp.armSseKeepAlive(this._keepAliveMs, () => {
|
|
try {
|
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
} catch {
|
|
if (timer !== void 0) clearInterval(timer);
|
|
}
|
|
});
|
|
return timer;
|
|
}
|
|
/** Sets the scope challenge resolver for parsed JSON-RPC requests. */
|
|
setScopeChallengeResolver(resolver) {
|
|
this._scopeChallengeResolver = resolver;
|
|
}
|
|
/**
|
|
* Starts the transport. This is required by the {@linkcode 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;
|
|
}
|
|
/**
|
|
* Sets the supported protocol versions for header validation.
|
|
* Called by the server during {@linkcode server/server.Server.connect | connect()} to pass its supported versions.
|
|
*/
|
|
setSupportedProtocolVersions(versions) {
|
|
this._supportedProtocolVersions = versions;
|
|
}
|
|
/**
|
|
* Helper to create a JSON error response
|
|
*/
|
|
createJsonErrorResponse(status, code, message, options) {
|
|
const error = {
|
|
code,
|
|
message
|
|
};
|
|
if (options?.data !== void 0) error.data = options.data;
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
error,
|
|
id: null
|
|
}, {
|
|
status,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...options?.headers
|
|
}
|
|
});
|
|
}
|
|
async _checkScopeChallenge(messages, authInfo) {
|
|
if (!this._scopeChallengeResolver) return;
|
|
const challenge = await require_mcp.findScopeChallenge(messages.filter((message) => require_src.isJSONRPCRequest(message)), authInfo, this._scopeChallengeResolver);
|
|
return challenge === void 0 ? void 0 : require_mcp.createScopeChallengeResponse(challenge, require_mcp.scopeChallengeResourceMetadataUrl(authInfo));
|
|
}
|
|
/**
|
|
* Validates request headers for DNS rebinding protection.
|
|
* @returns Error response if validation fails, `undefined` if validation passes.
|
|
*/
|
|
validateRequestHeaders(req) {
|
|
if (!this._enableDnsRebindingProtection) return;
|
|
if (this._allowedHosts && this._allowedHosts.length > 0) {
|
|
const hostHeader = req.headers.get("host");
|
|
if (!hostHeader || !this._allowedHosts.includes(hostHeader)) {
|
|
const error = `Invalid Host header: ${hostHeader}`;
|
|
this.onerror?.(new Error(error));
|
|
return this.createJsonErrorResponse(403, -32e3, error);
|
|
}
|
|
}
|
|
if (this._allowedOrigins && this._allowedOrigins.length > 0) {
|
|
const originHeader = req.headers.get("origin");
|
|
if (originHeader && !this._allowedOrigins.includes(originHeader)) {
|
|
const error = `Invalid Origin header: ${originHeader}`;
|
|
this.onerror?.(new Error(error));
|
|
return this.createJsonErrorResponse(403, -32e3, error);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Handles an incoming HTTP request, whether `GET`, `POST`, or `DELETE`
|
|
* Returns a `Response` object (Web Standard)
|
|
*/
|
|
async handleRequest(req, options) {
|
|
if (this._closed) return this.createJsonErrorResponse(404, -32001, "Session not found");
|
|
if (!this.sessionIdGenerator && this._hasHandledRequest) throw new Error("Stateless transport cannot be reused across requests. Create a new transport per request.");
|
|
this._hasHandledRequest = true;
|
|
const validationError = this.validateRequestHeaders(req);
|
|
if (validationError) return validationError;
|
|
switch (req.method) {
|
|
case "POST": return this.handlePostRequest(req, options);
|
|
case "GET": return this.handleGetRequest(req);
|
|
case "DELETE": return this.handleDeleteRequest(req);
|
|
default: return this.handleUnsupportedRequest();
|
|
}
|
|
}
|
|
/**
|
|
* Returns true if the client's protocol version supports empty SSE data in
|
|
* priming events (the fix shipped with protocol version `2025-11-25`).
|
|
*
|
|
* The version is checked for membership in this transport instance's
|
|
* supported protocol versions rather than with an open-ended
|
|
* `>= '2025-11-25'` comparison: the value may come from an `initialize`
|
|
* request body, which (unlike the `MCP-Protocol-Version` header) is not
|
|
* validated against `supportedProtocolVersions` before reaching this
|
|
* check. An unknown future version string must not silently enable
|
|
* behavior reserved for versions this transport actually supports.
|
|
*/
|
|
supportsEmptySSEData(protocolVersion) {
|
|
return this._supportedProtocolVersions.includes(protocolVersion) && protocolVersion >= "2025-11-25";
|
|
}
|
|
/**
|
|
* Writes a priming event to establish resumption capability.
|
|
* Only sends if `eventStore` is configured (opt-in for resumability) and
|
|
* the client's protocol version supports empty SSE data (a supported
|
|
* version that is >= `2025-11-25`).
|
|
*/
|
|
async writePrimingEvent(controller, encoder, streamId, protocolVersion) {
|
|
if (!this._eventStore) return;
|
|
if (!this.supportsEmptySSEData(protocolVersion)) return;
|
|
const primingEventId = await this._eventStore.storeEvent(streamId, {});
|
|
let primingEvent = `id: ${primingEventId}\ndata: \n\n`;
|
|
if (this._retryInterval !== void 0) primingEvent = `id: ${primingEventId}\nretry: ${this._retryInterval}\ndata: \n\n`;
|
|
controller.enqueue(encoder.encode(primingEvent));
|
|
}
|
|
/**
|
|
* Handles `GET` requests for SSE stream
|
|
*/
|
|
async handleGetRequest(req) {
|
|
if (!req.headers.get("accept")?.includes("text/event-stream")) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Not Acceptable: Client must accept text/event-stream"));
|
|
return this.createJsonErrorResponse(406, -32e3, "Not Acceptable: Client must accept text/event-stream");
|
|
}
|
|
const sessionError = this.validateSession(req);
|
|
if (sessionError) return sessionError;
|
|
const protocolError = this.validateProtocolVersion(req);
|
|
if (protocolError) return protocolError;
|
|
if (this._eventStore) {
|
|
const lastEventId = req.headers.get("last-event-id");
|
|
if (lastEventId) return this.replayEvents(lastEventId);
|
|
}
|
|
if (this._streamMapping.get(this._standaloneSseStreamId) !== void 0) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Conflict: Only one SSE stream is allowed per session"));
|
|
return this.createJsonErrorResponse(409, -32e3, "Conflict: Only one SSE stream is allowed per session");
|
|
}
|
|
const encoder = new TextEncoder();
|
|
let streamController;
|
|
let keepAliveTimer;
|
|
const readable = new ReadableStream({
|
|
start: (controller) => {
|
|
streamController = controller;
|
|
},
|
|
cancel: () => {
|
|
if (keepAliveTimer !== void 0) clearInterval(keepAliveTimer);
|
|
if (this._streamMapping.get(this._standaloneSseStreamId)?.controller === streamController) this._streamMapping.delete(this._standaloneSseStreamId);
|
|
}
|
|
});
|
|
const headers = {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no"
|
|
};
|
|
if (this.sessionId !== void 0) headers["mcp-session-id"] = this.sessionId;
|
|
this._streamMapping.set(this._standaloneSseStreamId, {
|
|
controller: streamController,
|
|
encoder,
|
|
cleanup: () => {
|
|
if (keepAliveTimer !== void 0) clearInterval(keepAliveTimer);
|
|
this._streamMapping.delete(this._standaloneSseStreamId);
|
|
try {
|
|
streamController.close();
|
|
} catch {}
|
|
}
|
|
});
|
|
keepAliveTimer = this.startKeepAlive(streamController, encoder);
|
|
return new Response(readable, { headers });
|
|
}
|
|
/**
|
|
* Replays events that would have been sent after the specified event ID
|
|
* Only used when resumability is enabled
|
|
*/
|
|
async replayEvents(lastEventId) {
|
|
if (!this._eventStore) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Event store not configured"));
|
|
return this.createJsonErrorResponse(400, -32e3, "Event store not configured");
|
|
}
|
|
try {
|
|
let streamId;
|
|
if (this._eventStore.getStreamIdForEventId) {
|
|
streamId = await this._eventStore.getStreamIdForEventId(lastEventId);
|
|
if (!streamId) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Invalid event ID format"));
|
|
return this.createJsonErrorResponse(400, -32e3, "Invalid event ID format");
|
|
}
|
|
if (this._streamMapping.get(streamId) !== void 0) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Conflict: Stream already has an active connection"));
|
|
return this.createJsonErrorResponse(409, -32e3, "Conflict: Stream already has an active connection");
|
|
}
|
|
}
|
|
const headers = {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no"
|
|
};
|
|
if (this.sessionId !== void 0) headers["mcp-session-id"] = this.sessionId;
|
|
const encoder = new TextEncoder();
|
|
let streamController;
|
|
let keepAliveTimer;
|
|
let cancelled = false;
|
|
let replayedStreamId;
|
|
const readable = new ReadableStream({
|
|
start: (controller) => {
|
|
streamController = controller;
|
|
},
|
|
cancel: () => {
|
|
cancelled = true;
|
|
if (keepAliveTimer !== void 0) clearInterval(keepAliveTimer);
|
|
if (replayedStreamId !== void 0 && this._streamMapping.get(replayedStreamId)?.controller === streamController) this._streamMapping.delete(replayedStreamId);
|
|
}
|
|
});
|
|
const replayedEventIds = /* @__PURE__ */ new Set();
|
|
replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { send: async (eventId, message) => {
|
|
replayedEventIds.add(eventId);
|
|
if (!this.writeSSEEvent(streamController, encoder, message, eventId)) try {
|
|
streamController.close();
|
|
} catch {}
|
|
} });
|
|
if (this._closed || cancelled) {
|
|
try {
|
|
streamController.close();
|
|
} catch {}
|
|
return this.createJsonErrorResponse(404, -32001, "Session not found");
|
|
}
|
|
this._streamMapping.get(replayedStreamId)?.cleanup();
|
|
this._streamMapping.set(replayedStreamId, {
|
|
controller: streamController,
|
|
encoder,
|
|
replayedEventIds,
|
|
cleanup: () => {
|
|
if (keepAliveTimer !== void 0) clearInterval(keepAliveTimer);
|
|
this._streamMapping.delete(replayedStreamId);
|
|
try {
|
|
streamController.close();
|
|
} catch {}
|
|
}
|
|
});
|
|
if (replayedStreamId !== this._standaloneSseStreamId) {
|
|
if (![...this._requestToStreamMapping.values()].includes(replayedStreamId)) {
|
|
this._streamMapping.delete(replayedStreamId);
|
|
try {
|
|
streamController.close();
|
|
} catch {}
|
|
}
|
|
}
|
|
if (this._streamMapping.get(replayedStreamId)?.controller === streamController) keepAliveTimer = this.startKeepAlive(streamController, encoder);
|
|
return new Response(readable, { headers });
|
|
} catch (error) {
|
|
this.onerror?.(error);
|
|
return this.createJsonErrorResponse(500, -32e3, "Error replaying events");
|
|
}
|
|
}
|
|
/**
|
|
* Writes an event to an SSE stream via controller with proper formatting
|
|
*/
|
|
writeSSEEvent(controller, encoder, message, eventId) {
|
|
try {
|
|
let eventData = `event: message\n`;
|
|
if (eventId) eventData += `id: ${eventId}\n`;
|
|
eventData += `data: ${JSON.stringify(message)}\n\n`;
|
|
controller.enqueue(encoder.encode(eventData));
|
|
return true;
|
|
} catch (error) {
|
|
this.onerror?.(error);
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Handles unsupported requests (`PUT`, `PATCH`, etc.)
|
|
*/
|
|
handleUnsupportedRequest() {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Method not allowed."));
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32e3,
|
|
message: "Method not allowed."
|
|
},
|
|
id: null
|
|
}, {
|
|
status: 405,
|
|
headers: {
|
|
Allow: "GET, POST, DELETE",
|
|
"Content-Type": "application/json"
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Handles `POST` requests containing JSON-RPC messages
|
|
*/
|
|
async handlePostRequest(req, options) {
|
|
try {
|
|
const acceptHeader = req.headers.get("accept");
|
|
if (!acceptHeader?.includes("application/json") || !acceptHeader.includes("text/event-stream")) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Not Acceptable: Client must accept both application/json and text/event-stream"));
|
|
return this.createJsonErrorResponse(406, -32e3, "Not Acceptable: Client must accept both application/json and text/event-stream");
|
|
}
|
|
if (!require_src.isJsonContentType(req.headers.get("content-type"))) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Unsupported Media Type: Content-Type must be application/json"));
|
|
return this.createJsonErrorResponse(415, -32e3, "Unsupported Media Type: Content-Type must be application/json");
|
|
}
|
|
const request = req;
|
|
let rawMessage;
|
|
if (options?.parsedBody === void 0) try {
|
|
const body = await readRequestBody(req, this._maxRequestBodySize);
|
|
if (body.tooLarge) {
|
|
const message = requestBodyTooLargeMessage(this._maxRequestBodySize);
|
|
this.onerror?.(new Error(message));
|
|
return this.createJsonErrorResponse(413, -32e3, message);
|
|
}
|
|
rawMessage = JSON.parse(body.text);
|
|
} catch (error) {
|
|
this.onerror?.(error);
|
|
return this.createJsonErrorResponse(400, -32700, "Parse error: Invalid JSON");
|
|
}
|
|
else rawMessage = options.parsedBody;
|
|
if (Array.isArray(rawMessage) && rawMessage.length > MAX_BATCH_SIZE) {
|
|
this.onerror?.(/* @__PURE__ */ new Error(`Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`));
|
|
return this.createJsonErrorResponse(400, -32600, `Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`);
|
|
}
|
|
let messages;
|
|
try {
|
|
messages = Array.isArray(rawMessage) ? rawMessage.map((msg) => _modelcontextprotocol_core_internal.JSONRPCMessageSchema.parse(msg)) : [_modelcontextprotocol_core_internal.JSONRPCMessageSchema.parse(rawMessage)];
|
|
} catch (error) {
|
|
this.onerror?.(error);
|
|
return this.createJsonErrorResponse(400, -32700, "Parse error: Invalid JSON-RPC message");
|
|
}
|
|
if (this._closed) return this.createJsonErrorResponse(404, -32001, "Session not found");
|
|
const isInitializationRequest = messages.some((element) => require_src.isInitializeRequest(element));
|
|
if (isInitializationRequest) {
|
|
if (this._initialized && this.sessionId !== void 0) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Invalid Request: Server already initialized"));
|
|
return this.createJsonErrorResponse(400, -32600, "Invalid Request: Server already initialized");
|
|
}
|
|
if (messages.length > 1) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Invalid Request: Only one initialization request is allowed"));
|
|
return this.createJsonErrorResponse(400, -32600, "Invalid Request: Only one initialization request is allowed");
|
|
}
|
|
this.sessionId = this.sessionIdGenerator?.();
|
|
this._initialized = true;
|
|
if (this.sessionId && this._onsessioninitialized) await Promise.resolve(this._onsessioninitialized(this.sessionId));
|
|
}
|
|
if (!isInitializationRequest) {
|
|
const sessionError = this.validateSession(req);
|
|
if (sessionError) return sessionError;
|
|
const protocolError = this.validateProtocolVersion(req);
|
|
if (protocolError) return protocolError;
|
|
}
|
|
if (this._closed) return this.createJsonErrorResponse(404, -32001, "Session not found");
|
|
let scopeChallengeResponse;
|
|
try {
|
|
scopeChallengeResponse = await this._checkScopeChallenge(messages, options?.authInfo);
|
|
} catch (error) {
|
|
this.onerror?.(error);
|
|
return this.createJsonErrorResponse(500, -32603, "Internal server error");
|
|
}
|
|
if (scopeChallengeResponse) return scopeChallengeResponse;
|
|
if (!messages.some((element) => require_src.isJSONRPCRequest(element))) {
|
|
for (const message of messages) this.onmessage?.(message, {
|
|
authInfo: options?.authInfo,
|
|
request
|
|
});
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
const streamId = crypto.randomUUID();
|
|
const initRequest = messages.find((m) => require_src.isInitializeRequest(m));
|
|
const clientProtocolVersion = initRequest ? initRequest.params.protocolVersion : req.headers.get("mcp-protocol-version") ?? _modelcontextprotocol_core_internal.DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
|
|
if (this._enableJsonResponse) return new Promise((resolve) => {
|
|
this._streamMapping.set(streamId, {
|
|
resolveJson: resolve,
|
|
cleanup: () => {
|
|
this._streamMapping.delete(streamId);
|
|
}
|
|
});
|
|
for (const message of messages) if (require_src.isJSONRPCRequest(message)) this._requestToStreamMapping.set(message.id, streamId);
|
|
for (const message of messages) this.onmessage?.(message, {
|
|
authInfo: options?.authInfo,
|
|
request
|
|
});
|
|
});
|
|
const encoder = new TextEncoder();
|
|
let streamController;
|
|
let keepAliveTimer;
|
|
const readable = new ReadableStream({
|
|
start: (controller) => {
|
|
streamController = controller;
|
|
},
|
|
cancel: () => {
|
|
if (keepAliveTimer !== void 0) clearInterval(keepAliveTimer);
|
|
if (this._streamMapping.get(streamId)?.controller === streamController) this._streamMapping.delete(streamId);
|
|
}
|
|
});
|
|
const headers = {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no"
|
|
};
|
|
if (this.sessionId !== void 0) headers["mcp-session-id"] = this.sessionId;
|
|
for (const message of messages) if (require_src.isJSONRPCRequest(message)) {
|
|
this._streamMapping.set(streamId, {
|
|
controller: streamController,
|
|
encoder,
|
|
cleanup: () => {
|
|
if (keepAliveTimer !== void 0) clearInterval(keepAliveTimer);
|
|
this._streamMapping.delete(streamId);
|
|
try {
|
|
streamController.close();
|
|
} catch {}
|
|
}
|
|
});
|
|
this._requestToStreamMapping.set(message.id, streamId);
|
|
}
|
|
await this.writePrimingEvent(streamController, encoder, streamId, clientProtocolVersion);
|
|
for (const message of messages) {
|
|
let closeSSEStream;
|
|
let closeStandaloneSSEStream;
|
|
if (require_src.isJSONRPCRequest(message) && this._eventStore && this.supportsEmptySSEData(clientProtocolVersion)) {
|
|
closeSSEStream = () => {
|
|
this.closeSSEStream(message.id);
|
|
};
|
|
closeStandaloneSSEStream = () => {
|
|
this.closeStandaloneSSEStream();
|
|
};
|
|
}
|
|
this.onmessage?.(message, {
|
|
authInfo: options?.authInfo,
|
|
request,
|
|
closeSSEStream,
|
|
closeStandaloneSSEStream
|
|
});
|
|
}
|
|
if (this._streamMapping.get(streamId)?.controller === streamController) keepAliveTimer = this.startKeepAlive(streamController, encoder);
|
|
return new Response(readable, {
|
|
status: 200,
|
|
headers
|
|
});
|
|
} catch (error) {
|
|
this.onerror?.(error);
|
|
return this.createJsonErrorResponse(400, -32700, "Parse error", { data: String(error) });
|
|
}
|
|
}
|
|
/**
|
|
* Handles `DELETE` requests to terminate sessions
|
|
*/
|
|
async handleDeleteRequest(req) {
|
|
const sessionError = this.validateSession(req);
|
|
if (sessionError) return sessionError;
|
|
const protocolError = this.validateProtocolVersion(req);
|
|
if (protocolError) return protocolError;
|
|
try {
|
|
await Promise.resolve(this._onsessionclosed?.(this.sessionId));
|
|
return new Response(null, { status: 200 });
|
|
} finally {
|
|
await this.close();
|
|
}
|
|
}
|
|
/**
|
|
* Validates session ID for non-initialization requests.
|
|
* Returns `Response` error if invalid, `undefined` otherwise
|
|
*/
|
|
validateSession(req) {
|
|
if (this.sessionIdGenerator === void 0) return;
|
|
if (!this._initialized) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Bad Request: Server not initialized"));
|
|
return this.createJsonErrorResponse(400, -32e3, "Bad Request: Server not initialized");
|
|
}
|
|
const sessionId = req.headers.get("mcp-session-id");
|
|
if (!sessionId) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Bad Request: Mcp-Session-Id header is required"));
|
|
return this.createJsonErrorResponse(400, -32e3, "Bad Request: Mcp-Session-Id header is required");
|
|
}
|
|
if (sessionId !== this.sessionId) {
|
|
this.onerror?.(/* @__PURE__ */ new Error("Session not found"));
|
|
return this.createJsonErrorResponse(404, -32001, "Session not found");
|
|
}
|
|
}
|
|
/**
|
|
* Validates the `MCP-Protocol-Version` header on incoming requests.
|
|
*
|
|
* For initialization: Version negotiation handles unknown versions gracefully
|
|
* (server responds with its supported version).
|
|
*
|
|
* For subsequent requests with `MCP-Protocol-Version` header:
|
|
* - Accept if in supported list
|
|
* - 400 if unsupported
|
|
*
|
|
* For HTTP requests without the `MCP-Protocol-Version` header:
|
|
* - Accept and default to the version negotiated at initialization
|
|
*/
|
|
validateProtocolVersion(req) {
|
|
const protocolVersion = req.headers.get("mcp-protocol-version");
|
|
if (protocolVersion !== null && !this._supportedProtocolVersions.includes(protocolVersion)) {
|
|
const error = `Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${this._supportedProtocolVersions.join(", ")})`;
|
|
this.onerror?.(new Error(error));
|
|
return this.createJsonErrorResponse(400, -32e3, error);
|
|
}
|
|
}
|
|
async close() {
|
|
if (this._closed) return;
|
|
this._closed = true;
|
|
for (const { cleanup } of this._streamMapping.values()) cleanup();
|
|
this._streamMapping.clear();
|
|
this._requestResponseMap.clear();
|
|
this.onclose?.();
|
|
}
|
|
/**
|
|
* Close an SSE stream for a specific request, triggering client reconnection.
|
|
* Use this to implement polling behavior during long-running operations -
|
|
* client will reconnect after the retry interval specified in the priming event.
|
|
*/
|
|
closeSSEStream(requestId) {
|
|
const streamId = this._requestToStreamMapping.get(requestId);
|
|
if (!streamId) return;
|
|
const stream = this._streamMapping.get(streamId);
|
|
if (stream) stream.cleanup();
|
|
}
|
|
/**
|
|
* Close the standalone `GET` SSE stream, triggering client reconnection.
|
|
* Use this to implement polling behavior for server-initiated notifications.
|
|
*/
|
|
closeStandaloneSSEStream() {
|
|
const stream = this._streamMapping.get(this._standaloneSseStreamId);
|
|
if (stream) stream.cleanup();
|
|
}
|
|
async send(message, options) {
|
|
let requestId = options?.relatedRequestId;
|
|
if (require_src.isJSONRPCResultResponse(message) || require_src.isJSONRPCErrorResponse(message)) requestId = message.id;
|
|
if (requestId === void 0) {
|
|
if (require_src.isJSONRPCResultResponse(message) || require_src.isJSONRPCErrorResponse(message)) throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request");
|
|
let eventId;
|
|
if (this._eventStore) eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message);
|
|
const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId);
|
|
if (standaloneSse === void 0) return;
|
|
if (standaloneSse.controller && standaloneSse.encoder && (eventId === void 0 || !standaloneSse.replayedEventIds?.has(eventId))) this.writeSSEEvent(standaloneSse.controller, standaloneSse.encoder, message, eventId);
|
|
return;
|
|
}
|
|
const streamId = this._requestToStreamMapping.get(requestId);
|
|
if (!streamId) throw new Error(`No connection established for request ID: ${String(requestId)}`);
|
|
let stream = this._streamMapping.get(streamId);
|
|
if (!this._enableJsonResponse) {
|
|
let eventId;
|
|
if (this._eventStore) {
|
|
eventId = await this._eventStore.storeEvent(streamId, message);
|
|
stream = this._streamMapping.get(streamId);
|
|
}
|
|
if (stream?.controller && stream?.encoder && (eventId === void 0 || !stream.replayedEventIds?.has(eventId))) this.writeSSEEvent(stream.controller, stream.encoder, message, eventId);
|
|
}
|
|
if (require_src.isJSONRPCResultResponse(message) || require_src.isJSONRPCErrorResponse(message)) {
|
|
this._requestResponseMap.set(requestId, message);
|
|
const relatedIds = [...this._requestToStreamMapping.entries()].filter(([_, sid]) => sid === streamId).map(([id]) => id);
|
|
if (relatedIds.every((id) => this._requestResponseMap.has(id))) {
|
|
if (!stream) {
|
|
if (this._enableJsonResponse) throw new Error(`No connection established for request ID: ${String(requestId)}`);
|
|
if (!this._eventStore) {
|
|
this.onerror?.(/* @__PURE__ */ new Error(`Response for request ID ${String(requestId)} is undeliverable: per-request stream is disconnected and no eventStore is configured`));
|
|
for (const id of relatedIds) {
|
|
this._requestResponseMap.delete(id);
|
|
this._requestToStreamMapping.delete(id);
|
|
}
|
|
return;
|
|
}
|
|
for (const id of relatedIds) {
|
|
this._requestResponseMap.delete(id);
|
|
this._requestToStreamMapping.delete(id);
|
|
}
|
|
return;
|
|
}
|
|
if (this._enableJsonResponse && stream.resolveJson) {
|
|
const headers = { "Content-Type": "application/json" };
|
|
if (this.sessionId !== void 0) headers["mcp-session-id"] = this.sessionId;
|
|
const responses = relatedIds.map((id) => this._requestResponseMap.get(id));
|
|
if (responses.length === 1) stream.resolveJson(Response.json(responses[0], {
|
|
status: 200,
|
|
headers
|
|
}));
|
|
else stream.resolveJson(Response.json(responses, {
|
|
status: 200,
|
|
headers
|
|
}));
|
|
stream.cleanup();
|
|
} else stream.cleanup();
|
|
for (const id of relatedIds) {
|
|
this._requestResponseMap.delete(id);
|
|
this._requestToStreamMapping.delete(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
//#endregion
|
|
//#region src/server/createMcpHandler.ts
|
|
/**
|
|
* The JSON-RPC id to echo on an entry-built error response: the body's `id`
|
|
* when the body is a single JSON-RPC request whose id is a string or number,
|
|
* `null` otherwise. Error responses must carry the id of the request they
|
|
* correspond to whenever it could be read; `null` is reserved for the cases
|
|
* where no single request id is determinable — unparseable bodies, body-less
|
|
* methods, notifications, posted responses and batch arrays.
|
|
*/
|
|
function echoableRequestId(body) {
|
|
if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
|
|
const { method, id } = body;
|
|
if (typeof method !== "string") return null;
|
|
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
}
|
|
function jsonRpcErrorResponse(httpStatus, code, message, data, id = null) {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code,
|
|
message,
|
|
...data !== void 0 && { data }
|
|
},
|
|
id
|
|
}, { status: httpStatus });
|
|
}
|
|
function rejectionResponse(rejection, id = null) {
|
|
return jsonRpcErrorResponse(rejection.httpStatus, rejection.code, rejection.message, rejection.data, id);
|
|
}
|
|
function toError(value) {
|
|
return value instanceof Error ? value : new Error(String(value));
|
|
}
|
|
function internalServerErrorResponse(id = null) {
|
|
return jsonRpcErrorResponse(500, -32603, "Internal server error", void 0, id);
|
|
}
|
|
/** Throws when the factory returned an instance that is still connected to another exchange. */
|
|
function assertNotServing(server) {
|
|
if (server.transport !== void 0) throw new Error("The factory returned an instance that is still serving another request. Return a new instance for each request.");
|
|
}
|
|
/**
|
|
* The entry's default legacy serving (`legacy: 'stateless'`): per-request
|
|
* stateless serving of 2025-era traffic using the same factory as the modern
|
|
* path. Exported as a standalone building block for hand-wired compositions
|
|
* (for example mounting legacy stateless serving on its own route next to a
|
|
* strict modern endpoint).
|
|
*
|
|
* Each POST is served by a fresh instance from the factory connected to a
|
|
* fresh streamable HTTP transport constructed with only
|
|
* `sessionIdGenerator: undefined` — the established stateless idiom, unchanged.
|
|
* Because serving is per-request and stateless, GET and DELETE (2025 session
|
|
* operations) are answered with `405` / `Method not allowed.`, exactly like the
|
|
* canonical stateless example.
|
|
*
|
|
* The optional `onerror` callback receives factory and serving failures on
|
|
* this leg (reporting only — the response stays the 500 internal-error body).
|
|
* The entry passes its own `onerror` here when expanding the default, so
|
|
* legacy-leg failures are never silently swallowed.
|
|
*/
|
|
function createLegacyStatelessFallback(factory, onerror, keepAliveMs, maxRequestBodySize) {
|
|
return async (request, options) => {
|
|
if (request.method.toUpperCase() !== "POST") return jsonRpcErrorResponse(405, -32e3, "Method not allowed.");
|
|
try {
|
|
const product = await factory({
|
|
era: "legacy",
|
|
...options?.authInfo !== void 0 && { authInfo: options.authInfo },
|
|
requestInfo: request
|
|
});
|
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
sessionIdGenerator: void 0,
|
|
...keepAliveMs !== void 0 && { keepAliveMs },
|
|
...maxRequestBodySize !== void 0 && { maxRequestBodySize }
|
|
});
|
|
await product.connect(transport);
|
|
const teardown = () => {
|
|
transport.close().catch(() => {});
|
|
product.close().catch(() => {});
|
|
};
|
|
request.signal?.addEventListener("abort", teardown, { once: true });
|
|
const response = await transport.handleRequest(request, {
|
|
...options?.authInfo !== void 0 && { authInfo: options.authInfo },
|
|
...options?.parsedBody !== void 0 && { parsedBody: options.parsedBody }
|
|
});
|
|
if (response.body === null || require_src.mediaTypeEssence(response.headers.get("content-type")) !== "text/event-stream") {
|
|
teardown();
|
|
return response;
|
|
}
|
|
const reader = response.body.getReader();
|
|
let toreDown = false;
|
|
const completeExchange = () => {
|
|
if (!toreDown) {
|
|
toreDown = true;
|
|
teardown();
|
|
}
|
|
};
|
|
const monitoredBody = new ReadableStream({
|
|
pull: async (controller) => {
|
|
try {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
completeExchange();
|
|
controller.close();
|
|
return;
|
|
}
|
|
if (value !== void 0) controller.enqueue(value);
|
|
} catch (error) {
|
|
completeExchange();
|
|
controller.error(error);
|
|
}
|
|
},
|
|
cancel: (reason) => {
|
|
completeExchange();
|
|
return reader.cancel(reason).catch(() => {});
|
|
}
|
|
});
|
|
return new Response(monitoredBody, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers
|
|
});
|
|
} catch (error) {
|
|
try {
|
|
onerror?.(toError(error));
|
|
} catch {}
|
|
return internalServerErrorResponse(echoableRequestId(options?.parsedBody));
|
|
}
|
|
};
|
|
}
|
|
function legacyStatelessFallback(factory, onerror, options) {
|
|
return createLegacyStatelessFallback(factory, onerror, void 0, resolveMaxRequestBodySize(options?.maxRequestBodySize));
|
|
}
|
|
/**
|
|
* Read the SEP-2243 standard request headers off the inbound request.
|
|
*
|
|
* Both halves of the standard-header story need them — the body-primary
|
|
* classifier for its cross-check cells, and
|
|
* {@linkcode validateStandardRequestHeaders} for the presence half — and a
|
|
* header read at one site but not the other is precisely how a required header
|
|
* goes unenforced: that divergence is what let a modern POST omitting
|
|
* `MCP-Protocol-Version` be served. Read them here once so a header added to
|
|
* {@linkcode InboundHttpRequest} reaches both sites together.
|
|
*/
|
|
function standardHeadersOf(request) {
|
|
return {
|
|
protocolVersionHeader: request.headers.get("mcp-protocol-version") ?? void 0,
|
|
mcpMethodHeader: request.headers.get("mcp-method") ?? void 0,
|
|
mcpNameHeader: request.headers.get("mcp-name") ?? void 0
|
|
};
|
|
}
|
|
/**
|
|
* The entry's classification step: read the request body exactly once (unless
|
|
* a pre-parsed body is supplied) and classify the request with
|
|
* {@linkcode classifyInboundRequest}. This is the single code path behind both
|
|
* {@linkcode createMcpHandler}'s routing and the exported
|
|
* {@linkcode isLegacyRequest} predicate, so the two can never disagree.
|
|
*
|
|
* Pass `needsForward: false` when the caller never reads `forwardRequest` —
|
|
* the body-preserving clone is then skipped and `forwardRequest` is the
|
|
* (consumed) input request.
|
|
*/
|
|
async function classifyEntryRequest(request, providedParsedBody, needsForward = true, maxRequestBodySize = DEFAULT_MAX_REQUEST_BODY_SIZE) {
|
|
const httpMethod = request.method.toUpperCase();
|
|
let body;
|
|
let parsedBody = providedParsedBody;
|
|
let forwardRequest = request;
|
|
let unparseable = false;
|
|
if (httpMethod === "POST") {
|
|
if (parsedBody === void 0) {
|
|
if (needsForward) forwardRequest = request.clone();
|
|
let bodyText;
|
|
try {
|
|
const read = await readRequestBody(request, maxRequestBodySize);
|
|
if (read.tooLarge) return { step: "body-too-large" };
|
|
bodyText = read.text;
|
|
} catch {
|
|
return { step: "unreadable-body" };
|
|
}
|
|
try {
|
|
body = bodyText.length === 0 ? void 0 : JSON.parse(bodyText);
|
|
} catch {
|
|
unparseable = true;
|
|
}
|
|
if (!unparseable && body !== void 0) parsedBody = body;
|
|
} else body = parsedBody;
|
|
if (unparseable || body === void 0) return {
|
|
step: "no-json-body",
|
|
forwardRequest
|
|
};
|
|
}
|
|
return {
|
|
step: "classified",
|
|
outcome: require_src.classifyInboundRequest({
|
|
httpMethod,
|
|
...standardHeadersOf(request),
|
|
...body !== void 0 && { body }
|
|
}),
|
|
body,
|
|
parsedBody,
|
|
forwardRequest
|
|
};
|
|
}
|
|
/**
|
|
* Whether {@linkcode createMcpHandler} would route this request to its legacy
|
|
* (2025-era) serving rather than the modern (2026-07-28) path.
|
|
*
|
|
* Call it with just the request: `await isLegacyRequest(request)`. For a
|
|
* `POST` the body is read from an internal clone, so the request you pass
|
|
* stays fully readable for whichever handler you route it to — no second
|
|
* argument is needed. (In a Node `(req, res)` handler, build that `Request`
|
|
* with `toWebRequest(req)` from `@modelcontextprotocol/node`; behind a body
|
|
* parser, which has already drained the Node stream, build it as
|
|
* `toWebRequest(req, req.body)` so the bytes come from the parsed body —
|
|
* either way the predicate still takes just the request.) The optional
|
|
* `parsedBody` is a perf escape hatch for a body you already hold parsed:
|
|
* pass it and the predicate classifies from the value directly, reading and
|
|
* cloning nothing. It is needed, not just faster, when the request's own
|
|
* body was already read — the internal clone is then impossible (cloning a
|
|
* used body throws a `TypeError`), so such a single-argument call rejects
|
|
* instead of guessing.
|
|
*
|
|
* This is the entry's own classification step exported as a predicate — it
|
|
* runs exactly the code `createMcpHandler` runs to make the routing decision,
|
|
* not a re-implementation — so a hand-wired composition that branches on it
|
|
* can never disagree with the entry. It is classification only: hand-wired
|
|
* compositions must validate Content-Type themselves (415 for POSTs whose
|
|
* media type is not `application/json`, via {@linkcode isJsonContentType})
|
|
* before dispatching either leg — routing the legacy leg into the SDK
|
|
* transports gets their built-in check, but a custom modern leg has none. Use it to keep an existing legacy
|
|
* deployment (for example a sessionful streamable HTTP wiring) serving 2025
|
|
* traffic next to a strict modern endpoint, now that the entry has no
|
|
* handler-valued `legacy` option:
|
|
*
|
|
* ```ts
|
|
* import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server';
|
|
*
|
|
* const modern = createMcpHandler(factory, { legacy: 'reject' });
|
|
*
|
|
* export default {
|
|
* async fetch(request: Request): Promise<Response> {
|
|
* if (await isLegacyRequest(request)) {
|
|
* // e.g. an existing sessionful WebStandardStreamableHTTPServerTransport wiring
|
|
* return myExistingLegacyHandler(request);
|
|
* }
|
|
* return modern.fetch(request);
|
|
* }
|
|
* };
|
|
* ```
|
|
*
|
|
* Semantics (identical to the entry's routing):
|
|
*
|
|
* - Returns `true` only for requests with no per-request `_meta` envelope
|
|
* claim: claim-less POSTs (including the `initialize` handshake and 2025-era
|
|
* notification POSTs without a modern protocol-version header), body-less
|
|
* GET/DELETE session operations, all-legacy JSON-RPC batch arrays, posted
|
|
* JSON-RPC responses, and POSTs whose body is empty or not valid JSON.
|
|
* - Returns `false` for everything the modern path answers, including its
|
|
* validation-ladder rejections: a request carrying the envelope claim (even
|
|
* one naming a revision the endpoint does not serve — the modern path
|
|
* answers it with the unsupported-protocol-version error), a malformed
|
|
* envelope behind a present claim (answered `-32602`), a request whose
|
|
* `MCP-Protocol-Version` header names a modern revision but that lacks the
|
|
* envelope (`-32602`), header/body mismatches (`-32020`), and a POST body
|
|
* over the size limit (413). Consumers
|
|
* routing on the predicate must send `false` traffic to the modern handler,
|
|
* never to a legacy handler — the modern path owns those error answers.
|
|
* - `server/discover` probes sent by negotiating clients always carry the
|
|
* envelope claim, so they are never legacy; a hand-built claim-less POST to
|
|
* a method named `server/discover` has no claim and classifies legacy,
|
|
* exactly as the entry itself routes it.
|
|
*/
|
|
async function isLegacyRequest(request, parsedBody, options) {
|
|
const maxRequestBodySize = resolveMaxRequestBodySize(options?.maxRequestBodySize);
|
|
const classified = await classifyEntryRequest(parsedBody === void 0 && request.method.toUpperCase() === "POST" ? request.clone() : request, parsedBody, false, maxRequestBodySize);
|
|
return classified.step === "no-json-body" || classified.step === "classified" && classified.outcome.kind === "legacy";
|
|
}
|
|
/**
|
|
* Creates an HTTP handler that serves the 2026-07-28 protocol revision from a
|
|
* per-request server factory and, by default, falls back to old-school
|
|
* stateless serving for 2025-era traffic. Pass `legacy: 'reject'` for a
|
|
* modern-only strict endpoint.
|
|
*
|
|
* Mounting: `handler.fetch` is the web-standard face (Cloudflare Workers,
|
|
* Deno, Bun, Hono's `c.req.raw`); for Express/Fastify/plain `node:http`, wrap
|
|
* the handler once with `toNodeHandler(handler)` from
|
|
* `@modelcontextprotocol/node`. When mounting bare on a fetch-native runtime,
|
|
* put Origin/Host validation in front of the handler — the entry itself is
|
|
* deliberately validation-free:
|
|
*
|
|
* ```ts
|
|
* import { hostHeaderValidationResponse, originValidationResponse, localhostAllowedHostnames, localhostAllowedOrigins } from '@modelcontextprotocol/server';
|
|
*
|
|
* export default {
|
|
* async fetch(request: Request): Promise<Response> {
|
|
* const rejected =
|
|
* hostHeaderValidationResponse(request, localhostAllowedHostnames()) ??
|
|
* originValidationResponse(request, localhostAllowedOrigins());
|
|
* return rejected ?? handler.fetch(request);
|
|
* }
|
|
* };
|
|
* ```
|
|
*
|
|
* Use ONE factory for both legs: the same tools/resources/prompts definition
|
|
* backs the modern path and the stateless legacy fallback, so the two eras can
|
|
* never drift apart. To keep an existing legacy deployment (for example a
|
|
* sessionful streamable HTTP wiring) serving 2025 traffic instead of the
|
|
* stateless fallback, route in user land with {@linkcode isLegacyRequest} in
|
|
* front of a strict handler — see that predicate's documentation for the
|
|
* pattern. Power users composing transport-neutral routing can also use the
|
|
* exported building blocks directly: {@linkcode classifyInboundRequest} for
|
|
* the era decision and `PerRequestHTTPServerTransport` for single-exchange
|
|
* serving — such compositions must reject POSTs whose Content-Type media type
|
|
* is not `application/json` (415) before parsing the body, using
|
|
* {@linkcode isJsonContentType}; neither building block performs this
|
|
* validation itself.
|
|
*
|
|
* The entry performs no token verification: `authInfo` given to `fetch` is
|
|
* passed through to handlers and the factory as-is and is never derived from
|
|
* request headers.
|
|
*/
|
|
function createMcpHandler(factory, options = {}) {
|
|
const { legacy, onerror, responseMode } = options;
|
|
const maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
|
|
if (typeof legacy === "function") throw new TypeError("The 'legacy' option only accepts 'stateless' or 'reject', not a handler function. To serve 2025-era traffic with your own handler, route in user land with the exported isLegacyRequest(request) predicate in front of a strict (legacy: 'reject') handler.");
|
|
/** Modern per-request instances with an exchange still in flight (close() tears these down). */
|
|
const inflight = /* @__PURE__ */ new Set();
|
|
let closed = false;
|
|
const reportError = (error) => {
|
|
try {
|
|
onerror?.(error);
|
|
} catch {}
|
|
};
|
|
const bus = options.bus ?? new require_mcp.InMemoryServerEventBus(reportError);
|
|
const notify = require_mcp.createServerNotifier(bus);
|
|
const listenRouter = require_mcp.createListenRouter({
|
|
bus,
|
|
maxSubscriptions: options.maxSubscriptions ?? require_mcp.DEFAULT_MAX_SUBSCRIPTIONS,
|
|
keepAliveMs: options.keepAliveMs ?? require_mcp.DEFAULT_SSE_KEEP_ALIVE_MS,
|
|
onerror: reportError
|
|
});
|
|
if (responseMode === "json") console.warn("responseMode: 'json' drops mid-call notifications. subscriptions/listen streams are always served over SSE regardless; other notifications emitted before a result are dropped.");
|
|
const legacyHandler = legacy === "reject" ? void 0 : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize);
|
|
async function serveModern(route, request, authInfo) {
|
|
const claimedRevision = route.classification.revision;
|
|
if (claimedRevision === void 0 || !require_src.SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedRevision)) {
|
|
const error = new require_src.UnsupportedProtocolVersionError({
|
|
supported: [...require_src.SUPPORTED_MODERN_PROTOCOL_VERSIONS],
|
|
requested: claimedRevision ?? "unknown"
|
|
});
|
|
reportError(error);
|
|
return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(route.message));
|
|
}
|
|
const stdHeaderRejection = require_src.validateStandardRequestHeaders({
|
|
httpMethod: request.method,
|
|
...standardHeadersOf(request)
|
|
}, route);
|
|
if (stdHeaderRejection !== void 0) {
|
|
reportError(/* @__PURE__ */ new Error(`Rejected inbound request (${stdHeaderRejection.cell}): ${stdHeaderRejection.message}`));
|
|
return rejectionResponse(stdHeaderRejection, echoableRequestId(route.message));
|
|
}
|
|
const meta = route.messageKind === "request" ? require_src.requestMetaOf(route.message.params) : void 0;
|
|
const declaredClientCapabilities = meta?.[_modelcontextprotocol_core_internal.CLIENT_CAPABILITIES_META_KEY];
|
|
if (route.messageKind === "request") {
|
|
const required = require_src.requiredClientCapabilitiesForRequest(route.message.method);
|
|
if (required !== void 0) {
|
|
const missing = require_src.missingClientCapabilities(required, declaredClientCapabilities);
|
|
if (missing !== void 0) {
|
|
const error = new require_src.MissingRequiredClientCapabilityError({ requiredCapabilities: missing });
|
|
reportError(error);
|
|
return jsonRpcErrorResponse(require_src.httpStatusForErrorCode(error.code, "ladder"), error.code, error.message, error.data, route.message.id);
|
|
}
|
|
}
|
|
}
|
|
const product = await factory({
|
|
era: "modern",
|
|
...authInfo !== void 0 && { authInfo },
|
|
requestInfo: request
|
|
});
|
|
const server = product instanceof require_mcp.McpServer ? product.server : product;
|
|
assertNotServing(server);
|
|
if (route.messageKind === "request" && route.message.method === "subscriptions/listen") {
|
|
const capabilities = server.getCapabilities();
|
|
const serverInfo = require_mcp.serverIdentityOf(server);
|
|
product.close().catch(reportError);
|
|
return listenRouter.serve(route.message, request.signal, capabilities, serverInfo);
|
|
}
|
|
if (route.messageKind === "request" && route.message.method === "tools/call" && product instanceof require_mcp.McpServer) {
|
|
const callParams = route.message.params;
|
|
const toolName = typeof callParams?.name === "string" ? callParams.name : void 0;
|
|
const inputSchema = toolName === void 0 ? void 0 : product.toolInputSchemaJson(toolName);
|
|
if (inputSchema !== void 0) {
|
|
const scan = require_src.scanXMcpHeaderDeclarations(inputSchema);
|
|
if (scan.valid && scan.declarations.length > 0) {
|
|
const rejection = require_src.validateMcpParamHeaders(scan.declarations, callParams?.arguments, request.headers);
|
|
if (rejection !== void 0) {
|
|
product.close().catch(reportError);
|
|
reportError(/* @__PURE__ */ new Error(`Rejected inbound request (${rejection.cell}): ${rejection.message}`));
|
|
return rejectionResponse(rejection, route.message.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (route.messageKind === "request" && product instanceof require_mcp.McpServer) try {
|
|
const challenge = await require_mcp.findScopeChallenge([route.message], authInfo, (context) => product.resolveScopeChallenge(context));
|
|
if (challenge !== void 0) {
|
|
product.close().catch(reportError);
|
|
return require_mcp.createScopeChallengeResponse(challenge, require_mcp.scopeChallengeResourceMetadataUrl(authInfo));
|
|
}
|
|
} catch (error) {
|
|
product.close().catch(reportError);
|
|
reportError(toError(error));
|
|
return internalServerErrorResponse(route.message.id);
|
|
}
|
|
assertNotServing(server);
|
|
require_src.setNegotiatedProtocolVersion(server, claimedRevision);
|
|
require_mcp.installModernOnlyHandlers(server, require_src.SUPPORTED_MODERN_PROTOCOL_VERSIONS);
|
|
if (meta !== void 0) require_mcp.seedClientIdentityFromEnvelope(server, {
|
|
clientInfo: meta[_modelcontextprotocol_core_internal.CLIENT_INFO_META_KEY],
|
|
clientCapabilities: declaredClientCapabilities
|
|
});
|
|
const previousOnClose = server.onclose;
|
|
inflight.add(server);
|
|
const onExchangeClose = () => {
|
|
inflight.delete(server);
|
|
if (server.onclose === onExchangeClose) server.onclose = previousOnClose;
|
|
previousOnClose?.();
|
|
};
|
|
server.onclose = onExchangeClose;
|
|
try {
|
|
const response = await invoke(product, route.message, {
|
|
classification: route.classification,
|
|
request,
|
|
...authInfo !== void 0 && { authInfo },
|
|
...responseMode !== void 0 && { responseMode },
|
|
...options.keepAliveMs !== void 0 && { keepAliveMs: options.keepAliveMs }
|
|
});
|
|
if (route.messageKind === "notification") queueMicrotask(() => void server.close().catch(() => {}));
|
|
return response;
|
|
} catch (error) {
|
|
if (error instanceof require_src.SdkError && error.code === require_src.SdkErrorCode.ConnectionClosed) return new Response(null, { status: 499 });
|
|
await server.close().catch(() => {});
|
|
inflight.delete(server);
|
|
reportError(toError(error));
|
|
return internalServerErrorResponse(echoableRequestId(route.message));
|
|
}
|
|
}
|
|
async function serveLegacyRoute(route, forwardRequest, authInfo, parsedBody) {
|
|
if (legacyHandler !== void 0) return legacyHandler(forwardRequest, {
|
|
...authInfo !== void 0 && { authInfo },
|
|
...parsedBody !== void 0 && { parsedBody }
|
|
});
|
|
const strict = require_src.modernOnlyStrictRejection(route, require_src.SUPPORTED_MODERN_PROTOCOL_VERSIONS);
|
|
if (strict === void 0) return new Response(null, { status: 202 });
|
|
reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only endpoint (${strict.cell}): ${strict.message}`));
|
|
return rejectionResponse(strict, echoableRequestId(parsedBody));
|
|
}
|
|
async function handle(request, requestOptions) {
|
|
const authInfo = requestOptions?.authInfo;
|
|
if (request.method.toUpperCase() === "POST" && !require_src.isJsonContentType(request.headers.get("content-type"))) {
|
|
reportError(/* @__PURE__ */ new Error("Unsupported Media Type: Content-Type must be application/json"));
|
|
return jsonRpcErrorResponse(415, -32e3, "Unsupported Media Type: Content-Type must be application/json");
|
|
}
|
|
const classified = await classifyEntryRequest(request, requestOptions?.parsedBody, true, maxRequestBodySize);
|
|
if (classified.step === "unreadable-body") return jsonRpcErrorResponse(400, -32700, "Parse error: the request body could not be read");
|
|
if (classified.step === "body-too-large") return jsonRpcErrorResponse(413, -32e3, requestBodyTooLargeMessage(maxRequestBodySize));
|
|
if (classified.step === "no-json-body") {
|
|
if (legacyHandler !== void 0) return legacyHandler(classified.forwardRequest, { ...authInfo !== void 0 && { authInfo } });
|
|
return jsonRpcErrorResponse(400, -32700, "Parse error: the request body is not valid JSON");
|
|
}
|
|
const { outcome, body, parsedBody, forwardRequest } = classified;
|
|
try {
|
|
switch (outcome.kind) {
|
|
case "reject":
|
|
reportError(/* @__PURE__ */ new Error(`Rejected inbound request (${outcome.cell}): ${outcome.message}`));
|
|
return rejectionResponse(outcome, echoableRequestId(body));
|
|
case "modern": return await serveModern(outcome, request, authInfo);
|
|
case "legacy": return await serveLegacyRoute(outcome, forwardRequest, authInfo, parsedBody);
|
|
}
|
|
} catch (error) {
|
|
reportError(toError(error));
|
|
return internalServerErrorResponse(echoableRequestId(body));
|
|
}
|
|
}
|
|
const fetchFace = async (request, requestOptions) => {
|
|
if (closed) throw new Error("This MCP handler has been closed");
|
|
try {
|
|
return await handle(request, requestOptions);
|
|
} catch (error) {
|
|
reportError(toError(error));
|
|
return internalServerErrorResponse(echoableRequestId(requestOptions?.parsedBody));
|
|
}
|
|
};
|
|
return {
|
|
fetch: fetchFace,
|
|
notify,
|
|
bus,
|
|
close: async () => {
|
|
closed = true;
|
|
listenRouter.closeAll();
|
|
const closing = [...inflight].map((server) => server.close().catch(() => {}));
|
|
inflight.clear();
|
|
await Promise.all(closing);
|
|
}
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/server/middleware/hostHeaderValidation.ts
|
|
/**
|
|
* Parse and validate a `Host` header against an allowlist of hostnames (port-agnostic).
|
|
*
|
|
* - Input host header may include a port (e.g. `localhost:3000`) or IPv6 brackets (e.g. `[::1]:3000`).
|
|
* - Allowlist items should be hostnames only (no ports). For IPv6, include brackets (e.g. `[::1]`).
|
|
*/
|
|
function validateHostHeader(hostHeader, allowedHostnames) {
|
|
if (!hostHeader) return {
|
|
ok: false,
|
|
errorCode: "missing_host",
|
|
message: "Missing Host header"
|
|
};
|
|
let hostname;
|
|
try {
|
|
hostname = new URL(`http://${hostHeader}`).hostname;
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
errorCode: "invalid_host_header",
|
|
message: `Invalid Host header: ${hostHeader}`,
|
|
hostHeader
|
|
};
|
|
}
|
|
if (!allowedHostnames.includes(hostname)) return {
|
|
ok: false,
|
|
errorCode: "invalid_host",
|
|
message: `Invalid Host: ${hostname}`,
|
|
hostHeader,
|
|
hostname
|
|
};
|
|
return {
|
|
ok: true,
|
|
hostname
|
|
};
|
|
}
|
|
/**
|
|
* Convenience allowlist for `localhost` DNS rebinding protection.
|
|
*/
|
|
function localhostAllowedHostnames() {
|
|
return [
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"[::1]"
|
|
];
|
|
}
|
|
/**
|
|
* Web-standard `Request` helper for DNS rebinding protection.
|
|
* @example
|
|
* ```ts source="./hostHeaderValidation.examples.ts#hostHeaderValidationResponse_basicUsage"
|
|
* const result = validateHostHeader(req.headers.get('host'), ['localhost']);
|
|
* ```
|
|
*/
|
|
function hostHeaderValidationResponse(req, allowedHostnames) {
|
|
const result = validateHostHeader(req.headers.get("host"), allowedHostnames);
|
|
if (result.ok) return void 0;
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32e3,
|
|
message: result.message
|
|
},
|
|
id: null
|
|
}, {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/server/middleware/originValidation.ts
|
|
/**
|
|
* Validate an `Origin` header against an allowlist of hostnames (port-agnostic).
|
|
*
|
|
* - A missing/empty `Origin` header passes: non-browser clients do not send one,
|
|
* and only browser-originated requests carry the header this check defends against.
|
|
* - Allowlist items are hostnames only (no scheme, no port), the same convention as
|
|
* `validateHostHeader`. For IPv6, include brackets (e.g. `[::1]`).
|
|
* - A browser extension's origin carries its extension ID as the hostname; list the
|
|
* ID to allow one extension. An allowlist item of the form `<scheme>://*`
|
|
* (lowercase, e.g. `moz-extension://*`) allows every origin of that scheme, which
|
|
* Firefox needs because its extension IDs differ on every install.
|
|
* `http://*` and `https://*` are not honoured: list websites by hostname.
|
|
* - Any present value that cannot be parsed as an origin URL — including the literal
|
|
* `null` origin browsers send for opaque contexts — is rejected (deny on failure).
|
|
*/
|
|
function validateOriginHeader(originHeader, allowedOriginHostnames) {
|
|
if (originHeader === null || originHeader === void 0 || originHeader === "") return { ok: true };
|
|
let hostname;
|
|
let scheme;
|
|
try {
|
|
const url = new URL(originHeader);
|
|
hostname = url.hostname;
|
|
scheme = url.protocol;
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
errorCode: "invalid_origin_header",
|
|
message: `Invalid Origin header: ${originHeader}`,
|
|
originHeader
|
|
};
|
|
}
|
|
if (hostname === "") return {
|
|
ok: false,
|
|
errorCode: "invalid_origin_header",
|
|
message: `Invalid Origin header: ${originHeader}`,
|
|
originHeader
|
|
};
|
|
if (!(scheme !== "http:" && scheme !== "https:" && allowedOriginHostnames.includes(`${scheme}//*`)) && !allowedOriginHostnames.includes(hostname)) return {
|
|
ok: false,
|
|
errorCode: "invalid_origin",
|
|
message: `Invalid Origin: ${hostname}`,
|
|
originHeader,
|
|
hostname
|
|
};
|
|
return {
|
|
ok: true,
|
|
origin: originHeader,
|
|
hostname
|
|
};
|
|
}
|
|
/**
|
|
* Convenience allowlist of localhost-class origin hostnames, mirroring
|
|
* `localhostAllowedHostnames`.
|
|
*/
|
|
function localhostAllowedOrigins() {
|
|
return [
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"[::1]"
|
|
];
|
|
}
|
|
/**
|
|
* Web-standard `Request` helper for Origin validation: returns a `403` JSON-RPC
|
|
* error response when the request's `Origin` header is not allowed, and
|
|
* `undefined` when the request may proceed.
|
|
*
|
|
* ```ts
|
|
* const rejected = originValidationResponse(request, localhostAllowedOrigins());
|
|
* if (rejected) return rejected;
|
|
* ```
|
|
*/
|
|
function originValidationResponse(req, allowedOriginHostnames) {
|
|
const result = validateOriginHeader(req.headers.get("origin"), allowedOriginHostnames);
|
|
if (result.ok) return void 0;
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32e3,
|
|
message: result.message
|
|
},
|
|
id: null
|
|
}, {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/server/requestStateCodec.ts
|
|
const PREFIX = "v1.";
|
|
function bytesToBase64Url(bytes) {
|
|
let bin = "";
|
|
for (const b of bytes) bin += String.fromCodePoint(b);
|
|
return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
}
|
|
function constantTimeTagEqual(a, b) {
|
|
if (a.length !== b.length) return false;
|
|
let r = 0;
|
|
for (let i = 0; i < a.length; i++) r |= a.codePointAt(i) ^ b.codePointAt(i);
|
|
return r === 0;
|
|
}
|
|
function base64UrlToBytes(s) {
|
|
const b64 = s.replaceAll("-", "+").replaceAll("_", "/");
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.codePointAt(i);
|
|
return bytes;
|
|
}
|
|
/**
|
|
* Create an opt-in HMAC-SHA256 codec for the multi-round-trip `requestState`
|
|
* (protocol revision 2026-07-28).
|
|
*
|
|
* `requestState` round-trips through the client and is attacker-controlled
|
|
* input on re-entry. The SDK applies no protection of its own; this helper is
|
|
* the convenience implementation of the spec's integrity MUST so authors don't
|
|
* hand-roll HMAC. Wire shape:
|
|
*
|
|
* "v1." b64url({"p":<payload>,"exp":<unixSeconds>,"b":<bindTag>?}) "." b64url(mac)
|
|
*
|
|
* where `bindTag` is `b64url(HMAC(key, "mcp.requestState.bind:" + bind(ctx))[:16])`
|
|
* — the binding value is never embedded raw.
|
|
*
|
|
* The codec is **signed, not encrypted**: the body is integrity-protected but
|
|
* the client can base64url-decode it and read the payload (`p`) in clear. Do
|
|
* not put secrets in the payload; use an AEAD construction if confidentiality
|
|
* is required. The handler reads its payload back via the typed
|
|
* `ctx.mcpReq.requestState<T>()` accessor — the seam has already run `verify`
|
|
* (integrity proven, payload decoded) by the time the handler is entered.
|
|
*
|
|
* Verification is fail-closed and constant-time (WebCrypto `subtle.verify` for
|
|
* the body MAC; a fixed-length XOR-accumulator compare for the bind tag).
|
|
* See `examples/mrtr/server.ts` for a worked end-to-end example.
|
|
*
|
|
* Design comparison (mcp.d `secureRequestState`, the peer SDK's reference
|
|
* implementation): mcp.d additionally offers an AES-256-GCM encrypted mode and
|
|
* derives independent cipher / bind-HMAC sub-keys from the operator secret via
|
|
* HKDF-SHA256, with an auto-generated per-process ephemeral key when none is
|
|
* supplied. This codec deliberately ships only the signed mode and a single
|
|
* keyed HMAC (domain-separated by input prefix) — HKDF sub-key derivation and
|
|
* an encrypted mode are intentionally out of scope for the initial release.
|
|
*/
|
|
function createRequestStateCodec(options) {
|
|
const subtle = globalThis.crypto?.subtle;
|
|
if (subtle === void 0) throw new TypeError("createRequestStateCodec requires the Web Crypto API (globalThis.crypto.subtle); see https://ts.sdk.modelcontextprotocol.io/v2/troubleshooting for the Node.js polyfill instructions");
|
|
const keyBytes = typeof options.key === "string" ? new TextEncoder().encode(options.key) : Uint8Array.from(options.key);
|
|
if (keyBytes.byteLength < 32) throw new RangeError(`createRequestStateCodec: key must be at least 32 bytes (got ${keyBytes.byteLength})`);
|
|
const ttlSeconds = options.ttlSeconds ?? 600;
|
|
if (!Number.isFinite(ttlSeconds)) throw new RangeError("createRequestStateCodec: ttlSeconds must be a finite number");
|
|
const bind = options.bind;
|
|
let cryptoKey;
|
|
const importedKey = () => cryptoKey ??= subtle.importKey("raw", keyBytes, {
|
|
name: "HMAC",
|
|
hash: "SHA-256"
|
|
}, false, ["sign", "verify"]);
|
|
const utf8 = new TextEncoder();
|
|
const BIND_LABEL = "mcp.requestState.bind:";
|
|
const bindTag = async (value) => {
|
|
return bytesToBase64Url(new Uint8Array(await subtle.sign("HMAC", await importedKey(), utf8.encode(BIND_LABEL + value))).slice(0, 16));
|
|
};
|
|
return {
|
|
async mint(payload, ctx) {
|
|
const envelope = {
|
|
p: payload,
|
|
exp: Math.floor(Date.now() / 1e3) + ttlSeconds
|
|
};
|
|
if (bind !== void 0) {
|
|
if (ctx === void 0) throw new TypeError("createRequestStateCodec: mint() requires ctx when a bind callback is configured");
|
|
envelope.b = await bindTag(bind(ctx));
|
|
}
|
|
const body = bytesToBase64Url(utf8.encode(JSON.stringify(envelope)));
|
|
return `${PREFIX}${body}.${bytesToBase64Url(new Uint8Array(await subtle.sign("HMAC", await importedKey(), utf8.encode(PREFIX + body))))}`;
|
|
},
|
|
async verify(state, ctx) {
|
|
const dot = state.lastIndexOf(".");
|
|
if (!state.startsWith(PREFIX) || dot <= 3) throw new Error("malformed");
|
|
const body = state.slice(3, dot);
|
|
let macBytes;
|
|
try {
|
|
macBytes = base64UrlToBytes(state.slice(dot + 1));
|
|
} catch {
|
|
throw new Error("malformed");
|
|
}
|
|
if (!await subtle.verify("HMAC", await importedKey(), macBytes, utf8.encode(PREFIX + body))) throw new Error("mac");
|
|
let envelope;
|
|
try {
|
|
envelope = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(base64UrlToBytes(body)));
|
|
} catch {
|
|
throw new Error("malformed");
|
|
}
|
|
if (typeof envelope.exp !== "number" || envelope.exp < Math.floor(Date.now() / 1e3)) throw new Error("expired");
|
|
if (bind !== void 0) {
|
|
const expected = await bindTag(bind(ctx));
|
|
if (envelope.b === void 0 || !constantTimeTagEqual(envelope.b, expected)) throw new Error("bind");
|
|
} else if (envelope.b !== void 0) throw new Error("bind");
|
|
return envelope.p;
|
|
}
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/fromJsonSchema.ts
|
|
let _defaultValidator;
|
|
function fromJsonSchema(schema, validator) {
|
|
return require_src.fromJsonSchema(schema, validator ?? (_defaultValidator ??= new _modelcontextprotocol_server__shims.DefaultJsonSchemaValidator()));
|
|
}
|
|
|
|
//#endregion
|
|
Object.defineProperty(exports, 'BAGGAGE_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.BAGGAGE_META_KEY;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'CLIENT_CAPABILITIES_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.CLIENT_CAPABILITIES_META_KEY;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'CLIENT_INFO_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.CLIENT_INFO_META_KEY;
|
|
}
|
|
});
|
|
exports.DEFAULT_MAX_REQUEST_BODY_SIZE = DEFAULT_MAX_REQUEST_BODY_SIZE;
|
|
Object.defineProperty(exports, 'DEFAULT_NEGOTIATED_PROTOCOL_VERSION', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
|
|
}
|
|
});
|
|
exports.DEFAULT_REQUEST_TIMEOUT_MSEC = require_src.DEFAULT_REQUEST_TIMEOUT_MSEC;
|
|
Object.defineProperty(exports, 'INTERNAL_ERROR', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.INTERNAL_ERROR;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'INVALID_PARAMS', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.INVALID_PARAMS;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'INVALID_REQUEST', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.INVALID_REQUEST;
|
|
}
|
|
});
|
|
exports.InMemoryServerEventBus = require_mcp.InMemoryServerEventBus;
|
|
exports.InMemoryTransport = require_src.InMemoryTransport;
|
|
Object.defineProperty(exports, 'JSONRPC_VERSION', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.JSONRPC_VERSION;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'LATEST_PROTOCOL_VERSION', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.LATEST_PROTOCOL_VERSION;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'LOG_LEVEL_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.LOG_LEVEL_META_KEY;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'METHOD_NOT_FOUND', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.METHOD_NOT_FOUND;
|
|
}
|
|
});
|
|
exports.McpServer = require_mcp.McpServer;
|
|
exports.MissingRequiredClientCapabilityError = require_src.MissingRequiredClientCapabilityError;
|
|
exports.OAuthError = require_src.OAuthError;
|
|
exports.OAuthErrorCode = require_src.OAuthErrorCode;
|
|
Object.defineProperty(exports, 'PARSE_ERROR', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.PARSE_ERROR;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'PROTOCOL_VERSION_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.PROTOCOL_VERSION_META_KEY;
|
|
}
|
|
});
|
|
exports.PerRequestHTTPServerTransport = PerRequestHTTPServerTransport;
|
|
exports.Protocol = require_src.Protocol;
|
|
exports.ProtocolError = require_src.ProtocolError;
|
|
exports.ProtocolErrorCode = require_src.ProtocolErrorCode;
|
|
Object.defineProperty(exports, 'RELATED_TASK_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.RELATED_TASK_META_KEY;
|
|
}
|
|
});
|
|
exports.ReadBuffer = require_src.ReadBuffer;
|
|
exports.ResourceNotFoundError = require_src.ResourceNotFoundError;
|
|
exports.ResourceTemplate = require_mcp.ResourceTemplate;
|
|
Object.defineProperty(exports, 'SERVER_INFO_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.SERVER_INFO_META_KEY;
|
|
}
|
|
});
|
|
exports.STDIO_DEFAULT_MAX_BUFFER_SIZE = require_src.STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
Object.defineProperty(exports, 'SUBSCRIPTION_ID_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.SUBSCRIPTION_ID_META_KEY;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'SUPPORTED_PROTOCOL_VERSIONS', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.SUPPORTED_PROTOCOL_VERSIONS;
|
|
}
|
|
});
|
|
exports.SdkError = require_src.SdkError;
|
|
exports.SdkErrorCode = require_src.SdkErrorCode;
|
|
exports.SdkHttpError = require_src.SdkHttpError;
|
|
exports.Server = require_mcp.Server;
|
|
Object.defineProperty(exports, 'TRACEPARENT_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.TRACEPARENT_META_KEY;
|
|
}
|
|
});
|
|
Object.defineProperty(exports, 'TRACESTATE_META_KEY', {
|
|
enumerable: true,
|
|
get: function () {
|
|
return _modelcontextprotocol_core_internal.TRACESTATE_META_KEY;
|
|
}
|
|
});
|
|
exports.UnsupportedProtocolVersionError = require_src.UnsupportedProtocolVersionError;
|
|
exports.UriTemplate = require_src.UriTemplate;
|
|
exports.UrlElicitationRequiredError = require_src.UrlElicitationRequiredError;
|
|
exports.WebStandardStreamableHTTPServerTransport = WebStandardStreamableHTTPServerTransport;
|
|
exports.acceptedContent = require_src.acceptedContent;
|
|
exports.assertCompleteRequestPrompt = require_src.assertCompleteRequestPrompt;
|
|
exports.assertCompleteRequestResourceTemplate = require_src.assertCompleteRequestResourceTemplate;
|
|
exports.bearerAuthChallengeResponse = require_mcp.bearerAuthChallengeResponse;
|
|
exports.buildOAuthProtectedResourceMetadata = require_mcp.buildOAuthProtectedResourceMetadata;
|
|
exports.checkResourceAllowed = require_src.checkResourceAllowed;
|
|
exports.classifyInboundRequest = require_src.classifyInboundRequest;
|
|
exports.completable = require_mcp.completable;
|
|
exports.createFetchWithInit = require_src.createFetchWithInit;
|
|
exports.createMcpHandler = createMcpHandler;
|
|
exports.createRequestStateCodec = createRequestStateCodec;
|
|
exports.deserializeMessage = require_src.deserializeMessage;
|
|
exports.fromJsonSchema = fromJsonSchema;
|
|
exports.getDisplayName = require_src.getDisplayName;
|
|
exports.getOAuthProtectedResourceMetadataUrl = require_mcp.getOAuthProtectedResourceMetadataUrl;
|
|
exports.hostHeaderValidationResponse = hostHeaderValidationResponse;
|
|
exports.inputRequired = require_src.inputRequired;
|
|
exports.inputResponse = require_src.inputResponse;
|
|
exports.isCallToolResult = require_src.isCallToolResult;
|
|
exports.isCompletable = require_mcp.isCompletable;
|
|
exports.isInitializeRequest = require_src.isInitializeRequest;
|
|
exports.isInitializedNotification = require_src.isInitializedNotification;
|
|
exports.isInputRequiredResult = require_src.isInputRequiredResult;
|
|
exports.isJSONRPCErrorResponse = require_src.isJSONRPCErrorResponse;
|
|
exports.isJSONRPCNotification = require_src.isJSONRPCNotification;
|
|
exports.isJSONRPCRequest = require_src.isJSONRPCRequest;
|
|
exports.isJSONRPCResponse = require_src.isJSONRPCResponse;
|
|
exports.isJSONRPCResultResponse = require_src.isJSONRPCResultResponse;
|
|
exports.isJsonContentType = require_src.isJsonContentType;
|
|
exports.isLegacyRequest = isLegacyRequest;
|
|
exports.isSpecType = require_src.isSpecType;
|
|
exports.isTaskAugmentedRequestParams = require_src.isTaskAugmentedRequestParams;
|
|
exports.legacyStatelessFallback = legacyStatelessFallback;
|
|
exports.localhostAllowedHostnames = localhostAllowedHostnames;
|
|
exports.localhostAllowedOrigins = localhostAllowedOrigins;
|
|
exports.mergeCapabilities = require_src.mergeCapabilities;
|
|
exports.oauthMetadataResponse = require_mcp.oauthMetadataResponse;
|
|
exports.originValidationResponse = originValidationResponse;
|
|
exports.parseJSONRPCMessage = require_src.parseJSONRPCMessage;
|
|
exports.preloadSchemas = require_src.preloadSchemas;
|
|
exports.readRequestBody = readRequestBody;
|
|
exports.requireBearerAuth = require_mcp.requireBearerAuth;
|
|
exports.requireScopes = require_mcp.requireScopes;
|
|
exports.resourceUrlFromServerUrl = require_src.resourceUrlFromServerUrl;
|
|
exports.serializeMessage = require_src.serializeMessage;
|
|
exports.specTypeSchemas = require_src.specTypeSchemas;
|
|
exports.validateHostHeader = validateHostHeader;
|
|
exports.validateOriginHeader = validateOriginHeader;
|
|
exports.verifyBearerToken = require_mcp.verifyBearerToken;
|
|
//# sourceMappingURL=index.cjs.map |