import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; import { Effect, Match, Predicate } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { defaultMcpResource, McpAuthProvider, McpErrorReporter, McpSessionStore, type AuthOutcome, type McpDispatchResult, type McpResource, } from "./seams"; // --------------------------------------------------------------------------- // Provider-neutral MCP serving envelope. // // Routes: // GET -> McpAuthProvider metadata // * /mcp -> authenticate -> dispatch(default) // * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit) // // The provider DECLARES the discovery paths it owns (at least the protected- // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the // envelope never hard-codes `/.well-known/oauth-*`. The OAuth endpoints // (/authorize, /token, /register) stay OUT of the envelope: they are served by // the provider's own handler (self-host: Better Auth at /api/auth; cloud: // WorkOS, external). The envelope only needs the provider's discovery routes, // resource-metadata URL, and authenticate. // // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz // semantics, and the entire session lifecycle (create + forward + ownership) — // comes from the two seams. // // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport // works on web `Request`/`Response`, so the envelope reconstructs the inbound // web request once, hands it to the store, and converts the store's `Response` // into an Effect response while preserving both streaming bodies and outer // metadata (the latter matters when the HTTP adapter strips a HEAD body). // --------------------------------------------------------------------------- const MCP_PATH = "/mcp"; const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; /** The methods the streamable-HTTP transport accepts on `/mcp`. */ const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); /** * Preserve a WHATWG response's status and headers on the Effect wrapper. * * Passing only `response` to `HttpServerResponse.raw` works for ordinary * requests because `toWeb` returns the nested Response verbatim. For HEAD, * however, the adapter intentionally omits the nested body and serializes the * outer wrapper instead; without copied metadata that becomes an empty 200 * with no content type. */ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerResponse => HttpServerResponse.raw(response, { status: response.status, statusText: response.statusText, headers: response.headers, }); /** * The canonical CORS preflight `Response` (204) answered for an `OPTIONS` on * `/mcp` AND on every provider-declared discovery path. A browser issues a * preflight against the metadata docs too (RFC 9728 discovery from a 401), so * the envelope answers OPTIONS for those paths, not only `/mcp`. */ const corsPreflightResponse = (): Response => new Response(null, { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", "access-control-allow-headers": "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", }, }); /** * The canonical JSON-RPC error `Response` builder for every MCP serving site. * * Emits the EXACT body every host renders — `{jsonrpc:"2.0",error:{code,message}, * id:null}` — with `content-type: application/json`. Two header policies: * * - `cors: true` (default) adds `access-control-allow-origin: *`. This is the * envelope's policy and the cloud edge worker's (`jsonRpcWebResponse`): * errors cross the browser boundary, so they carry CORS. A `challenge` * additionally emits the `WWW-Authenticate` header + exposes it via CORS * (the 401 path). * - `cors: false` omits CORS entirely — for INNER responses that never reach * the browser directly (the cloud Durable Object and the self-host /local * in-process stores, whose `Response` is post-processed / re-wrapped with * CORS by the outer envelope before it leaves the origin). * * One renderer, byte-identical bodies across host-mcp + cloud + self-host + * local — the four hand-rolled copies are deleted in favor of this. */ export const jsonRpcErrorBody = ( status: number, code: number, message: string, opts?: { readonly cors?: boolean; readonly challenge?: string; readonly retryAfterSeconds?: number; /** * The id to echo. Envelope-level errors have no request to answer and stay * at the default `null`; only an error that answers ONE identified JSON-RPC * request (the pre-initialize guard below) sets it, since a client matches * the response to its pending request by id. */ readonly id?: string | number | null; }, ): Response => { const cors = opts?.cors ?? true; const challenge = opts?.challenge; const retryAfterSeconds = opts?.retryAfterSeconds; return new Response( JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: opts?.id ?? null }), { status, headers: { "content-type": "application/json", ...(cors ? { "access-control-allow-origin": "*" } : {}), ...(challenge ? { "www-authenticate": challenge, "access-control-expose-headers": "WWW-Authenticate", } : {}), ...(retryAfterSeconds === undefined ? {} : { "retry-after": String(retryAfterSeconds) }), }, }, ); }; /** * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP * membership lookups) are typically sub-second. */ export const UNAVAILABLE_RETRY_AFTER_SECONDS = 2; /** JSON-RPC's own code for "this server does not implement that method". */ const METHOD_NOT_FOUND = -32601; /** * The transport's POST content negotiation, mirrored. * * `WebStandardStreamableHTTPServerTransport.handlePostRequest` refuses a POST * on its headers alone, BEFORE it reads the body: * * - `Accept` must list BOTH `application/json` and `text/event-stream`, else * 406 Not Acceptable. * - `Content-Type` must include `application/json`, else 415 Unsupported * Media Type. * * The guard below answers 200 without ever consulting the transport, so it must * not fire on a request the transport would have refused — that would turn a * 406 or a 415 into a success. These are the SDK's own substring tests on the * raw header values, copied so the two agree; a request that fails either falls * through untouched and gets the transport's real 406/415. * * The transport's DNS-rebinding check runs earlier still, but it is inert * unless a host passes `enableDnsRebindingProtection`, which no host here does. */ const passesTransportContentNegotiation = (request: Request): boolean => { const accept = request.headers.get("accept"); if (!accept?.includes("application/json") || !accept.includes("text/event-stream")) return false; const contentType = request.headers.get("content-type"); return contentType !== null && contentType.includes("application/json"); }; /** * The pre-initialize dispatch guard, for the session-less POST every host * handles before it hands the request to a fresh streamable-HTTP transport. * * Only `initialize` can open a session, so the transport answers every OTHER * method with HTTP **400** + `-32000 Server not initialized`. A 400 is a * transport-level failure: clients tear the connection down rather than treat * it as one request failing. So a client that opens with an optional probe — * MCP 2026-07-28 clients lead with `server/discover` — is disconnected before * it can fall back to `initialize`, and the handshake never happens. * * JSON-RPC already has the right answer for a method the dispatcher doesn't * know: `-32601 Method not found`, carried on a normal HTTP 200. It is a * per-request error, so the connection survives and the client falls back. * * This deliberately covers ANY method other than `initialize` rather than * naming `server/discover`: pre-session, `initialize` is the only method this * dispatcher implements, so -32601 is the literal truth for the rest. It also * can't silently mask a future real `server/discover` — implementing it means * it stops being unknown here, instead of a special case going stale. * * The guard REPLACES one transport answer and must not shadow any of the * others, so it fires only where it is the whole story: a POST that clears the * transport's content negotiation AND carries a structurally valid JSON-RPC 2.0 * request. Everything else succeeds with `null` and reaches the transport * untouched, keeping the transport's own response — a non-POST, a bad * `Accept`/`Content-Type` (406/415), unparseable JSON or a message that is not * a valid JSON-RPC request (400 parse error: a fractional id, a non-object * `params`, an unknown top-level field), a batch, a notification or a response * (no id to answer), and `initialize` itself. * * Structural validity is decided by the SDK's own `isJSONRPCRequest`, the exact * predicate behind the transport's `JSONRPCMessageSchema.parse`, rather than a * hand-rolled re-implementation that could drift from it. * * Reads a clone, leaving the caller's request body intact for the transport. */ export const preInitializeMethodNotFound = (request: Request): Effect.Effect => request.method !== "POST" || !passesTransportContentNegotiation(request) ? Effect.succeed(null) : Effect.tryPromise({ try: (): Promise => request.clone().json(), catch: () => null, }).pipe( // A body we cannot read is simply not ours to answer; hand it on. Effect.orElseSucceed(() => null), Effect.map(renderPreInitializeMethodNotFound), ); /** The pure decision behind {@link preInitializeMethodNotFound}. */ const renderPreInitializeMethodNotFound = (body: unknown): Response | null => { if (!isJSONRPCRequest(body)) return null; if (body.method === "initialize") return null; // An INNER response like every other store/handler error body: the host's // outer envelope owns the CORS headers on the way out. return jsonRpcErrorBody(200, METHOD_NOT_FOUND, "Method not found", { cors: false, id: body.id, }); }; /** The envelope's own CORS-on JSON-RPC error `Response`, optionally carrying a challenge. */ const jsonRpcResponse = ( status: number, code: number, message: string, challenge?: string, ): Response => challenge === undefined ? jsonRpcErrorBody(status, code, message) : jsonRpcErrorBody(status, code, message, { challenge }); /** * Reconstruct a WHATWG `Request` from the Effect HTTP request. Prefer the * underlying source `Request` (preserves the body stream the transport reads); * otherwise rebuild from parts. A failed body read is a defect here, not a * recoverable error. */ const toWebRequest = (req: HttpServerRequest.HttpServerRequest): Effect.Effect => Effect.gen(function* () { if (req.source instanceof Request) return req.source; const headers = new Headers(req.headers as Record); const hasBody = req.method !== "GET" && req.method !== "HEAD"; // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: rebuilding a web Request from a non-web source; a failed body read is an unrecoverable infra defect, not a domain error const body = hasBody ? yield* req.text.pipe(Effect.orDie) : undefined; return new Request(req.url, { method: req.method, headers, body }); }); /** Serve a provider discovery document, wrapping its web `Response`. */ const discoveryRoute = (handler: (request: Request) => Effect.Effect) => Effect.gen(function* () { const httpRequest = yield* HttpServerRequest.HttpServerRequest; const request = yield* toWebRequest(httpRequest); const response = yield* handler(request); return fromWebResponse(response); }); /** * Render a non-`Authenticated` {@link AuthOutcome} to a web `Response`: * Unauthorized -> 401 + RFC 9728 challenge (outcome's own, else a default * built from the provider's `resourceMetadataUrl`) * Forbidden -> 403 JSON-RPC (default code -32001) * Unavailable -> 503 JSON-RPC -32001 + Retry-After * * The transient 503 and the session-lifecycle 404 both use JSON-RPC code * -32001 (the shared "auth/session envelope error" code); clients discriminate * on the HTTP status, which is the contract — 503 means retry the SAME * session, 404 means the session id is dead and the client must reconnect. */ const renderAuthError = ( auth: McpAuthProvider["Service"], request: Request, outcome: Exclude, ): Response => Match.value(outcome).pipe( Match.tag("Unauthorized", (u) => jsonRpcResponse( 401, -32001, "Unauthorized", u.challenge ?? `Bearer resource_metadata="${auth.resourceMetadataUrl(request)}"`, ), ), Match.tag("Forbidden", (f) => jsonRpcResponse(403, f.code ?? -32001, f.message)), Match.tag("Unavailable", (u) => jsonRpcErrorBody(503, -32001, u.message, { retryAfterSeconds: UNAVAILABLE_RETRY_AFTER_SECONDS, }), ), Match.exhaustive, ); /** * Render a non-`Response` {@link McpDispatchResult} discriminant. A dead * session answers by method: POST/DELETE keep the 404 that drives client * re-initialization; a standalone GET gets 405, which the v1 SDK reads as * "no SSE stream offered" and stops retrying — breaking pre-cutover * reconnect loops whose GET-404 path never re-initialized. */ const renderDispatchError = (lookup: "not-found" | "forbidden", method: string): Response => { if (lookup === "forbidden") { return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); } if (method === "GET") { return new Response( JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Session not found" }, id: null, }), { status: 405, headers: { "content-type": "application/json", allow: "POST, DELETE" } }, ); } return jsonRpcResponse(404, -32001, "Session not found"); }; /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ const mcpDispatch = (resource: McpResource) => Effect.gen(function* () { const httpRequest = yield* HttpServerRequest.HttpServerRequest; const auth = yield* McpAuthProvider; const store = yield* McpSessionStore; const request = yield* toWebRequest(httpRequest); // CORS preflight: answer before auth so unauthenticated clients can probe. if (request.method === "OPTIONS") { return fromWebResponse(corsPreflightResponse()); } // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — // otherwise it would fall through and spin up a session engine for a method // the transport can't serve. if (!ALLOWED_MCP_METHODS.has(request.method)) { return fromWebResponse(jsonRpcResponse(405, -32001, "Method not allowed")); } const sessionId = request.headers.get("mcp-session-id"); // Authenticate (and, for session-aware providers, authorize) on EVERY // request. Non-authenticated outcomes render directly. Session teardown is // only safe after the store can validate the authenticated principal and MCP // resource; an auth-level Forbidden may not carry either. const outcome = yield* auth.authenticate(request); if (!Predicate.isTagged(outcome, "Authenticated")) { return fromWebResponse(renderAuthError(auth, request, outcome)); } const principal = outcome.principal; // No session id: per the streamable-HTTP transport contract, only POST opens // a session. A GET needs an existing id (400); a DELETE on nothing is a // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up // an engine for a bare GET/DELETE. if (!sessionId) { if (request.method === "GET") { return fromWebResponse( jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), ); } if (request.method === "DELETE") { return fromWebResponse( new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), ); } } const result: McpDispatchResult = yield* store.dispatch({ request, principal, resource, sessionId, method: request.method, }); return fromWebResponse( result instanceof Response ? result : renderDispatchError(result, request.method), ); }); /** * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a * request-orchestration defect (a rejected cross-isolate RPC, a body-tee * failure, …) is reported to the optional {@link McpErrorReporter} (Sentry / * `ErrorCapture` parity — the provider's capture pipeline would never see it * otherwise, since the envelope returns a `Response`) and rendered as a stable * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. */ const mcpRoute = (resource: McpResource) => mcpDispatch(resource).pipe( Effect.catchCause((cause) => Effect.gen(function* () { const reporter = yield* McpErrorReporter; yield* reporter.report(cause); return fromWebResponse(jsonRpcResponse(500, -32603, "Internal server error")); }), ), ); const toolkitMcpRoute = Effect.gen(function* () { const params = yield* HttpRouter.params; const slug = params.toolkitSlug; return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); }); /** * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges * this with its other routes and provides the two seam Layers + the HTTP * platform services. Provider-neutral: cloud adopts the same Layer next. * * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so * the provider — not the envelope — owns its `/.well-known/oauth-*` paths. An * `OPTIONS` on each discovery path answers the same CORS preflight as `/mcp` * (a browser preflights the metadata docs during RFC 9728 discovery). */ export const McpServingRoutes = HttpRouter.use((router) => Effect.gen(function* () { const auth = yield* McpAuthProvider; for (const route of auth.discoveryRoutes) { yield* router.add("GET", route.path, discoveryRoute(route.handler)); yield* router.add( "OPTIONS", route.path, Effect.sync(() => fromWebResponse(corsPreflightResponse())), ); } yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); }), ); /** * The discovery-only subset of {@link McpServingRoutes}: the provider-declared * `/.well-known/oauth-*` GET routes plus their CORS preflight, and NOTHING on * `/mcp`. Requires only the {@link McpAuthProvider} seam, not {@link McpSessionStore}. * * For a host whose `/mcp` transport is served outside this envelope (the * Cloudflare Agent bridge intercepts `/mcp` before the app handler) but that * still needs to publish its OAuth metadata docs. Such a host wires the `auth` * seam without a `sessions` seam. */ export const McpDiscoveryRoutes = HttpRouter.use((router) => Effect.gen(function* () { const auth = yield* McpAuthProvider; for (const route of auth.discoveryRoutes) { yield* router.add("GET", route.path, discoveryRoute(route.handler)); yield* router.add( "OPTIONS", route.path, Effect.sync(() => fromWebResponse(corsPreflightResponse())), ); } }), );