import { Cause, Data, Effect, Layer } from "effect"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; import { buildResumeApprovalUrl, decodeResumeResponse, formatResumeAcknowledgement, readArtifactsEnabled, readElicitationMode, } from "./browser-approval"; import { makeInProcessBrowserApprovalStore, type InProcessBrowserApprovalStore, } from "./browser-approval-store"; import { jsonRpcErrorBody } from "./envelope"; import { McpSessionStore, defaultMcpResource, mcpResourceKey, principalOwns, type McpDispatchInput, type McpDispatchResult, type Principal, type McpResource, } from "./seams"; import type { BrowserApprovalStore } from "./tool-server"; // --------------------------------------------------------------------------- // In-process McpSessionStore — the single-node serving store, shared by every // host that has no cross-isolate session backend (self-host, the local app). // Cloud's Durable Object store is the cross-isolate variant of the same // `McpSessionStore` seam. // // In the two-seam envelope the store owns the ENTIRE session lifecycle via // `dispatch`: create (no session id + POST initialize), forward (session id // present), and ownership (cross-bearer). Maps keyed by mcp-session-id hold the // live in-process sessions: transports, servers, owners, and — for the browser // approval flow — the per-session engines. // // Browser approval: when the create request carries `?elicitation_mode=browser`, // the store builds the session's server in browser mode (an `approvalUrl` + the // shared in-process approval store) and keeps the session's engine so the HTTP // approval endpoints (`handlePausedRequest` / `handleApprovalRequest`) can read // the paused execution and record the human's decision. The Durable Object // hosts do the equivalent with `ctx.storage`. // // `dispatch` returns the transport `Response` to pass through, or: // - "not-found" (unknown session id) -> envelope renders 404 -32001 // - "forbidden" (session owned by another bearer) -> envelope renders 403 -32003 // --------------------------------------------------------------------------- /** Engine construction failed for a principal. The store surfaces it as a 500. */ export class McpEngineBuildError extends Data.TaggedError("McpEngineBuildError")<{ readonly cause: unknown; }> {} /** The connected MCP server plus the engine the approval endpoints drive. */ export interface BuiltMcpServer { readonly mcpServer: McpServer; readonly engine: ExecutionEngine; } /** The browser-mode wiring the store hands a build call when a session opts in. */ export interface McpBuildServerOptions { readonly resource?: McpResource; readonly elicitationMode?: | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } | { readonly mode: "model" } | { readonly mode: "native" }; readonly browserApprovalStore?: BrowserApprovalStore; /** Whether this session serves artifacts. True unless the client connected * with `?artifacts=false`; opted out, the built server registers none of * the artifact tools, resource, or skills. */ readonly artifactsEnabled?: boolean; } /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ export type McpBuildServer = ( principal: Principal, options?: McpBuildServerOptions, ) => Effect.Effect; export interface InMemoryMcpSessionStore { /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */ readonly store: McpSessionStore["Service"]; /** * Serve `GET /api/mcp-sessions/:sessionId/executions/:executionId` — the * paused-execution detail the console approval page renders. Returns the * paused `{ text, structured }` or a 404. Null if the path does not match. */ readonly handlePausedRequest: ( request: Request, principal?: Principal, ) => Promise; /** * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` — * record the human's decision and wake the long-polling `resume` tool call. * Null if the path does not match. */ readonly handleApprovalRequest: ( request: Request, principal?: Principal, ) => Promise; /** Dispose every live session — wire into the host's shutdown (not a seam). */ readonly close: () => Promise; } const ignoreClose = (close: (() => Promise) | undefined): Promise => close ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined }))) : Promise.resolve(); const formatBoundaryError = (error: unknown): unknown => // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: log unknown MCP SDK/runtime failures error instanceof Error ? (error.stack ?? error.message) : error; // The store's error bodies are INNER responses (no CORS): the serving envelope // re-wraps the store `Response` with CORS before it leaves the origin, so the // canonical renderer is called with `cors: false` (content-type only). const jsonRpcError = (status: number, code: number, message: string): Response => jsonRpcErrorBody(status, code, message, { cors: false }); const json = (value: unknown, status = 200): Response => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; interface SessionOwner { readonly principal: Principal; readonly resource: McpResource; } const sessionOwnerMatches = ( owner: SessionOwner, principal: Principal, resource: McpResource, ): boolean => principalOwns(owner.principal, principal) && mcpResourceKey(owner.resource) === mcpResourceKey(resource); /** * Build the in-process session store plus an explicit `close()` that disposes * all live sessions. `close()` is not part of the seam — it is the host lifetime * hook the envelope doesn't own. Each per-session engine comes from the * host-supplied `buildServer`. */ export const makeInMemoryMcpSessionStore = ( buildServer: McpBuildServer, // The host's pinned public origin, used to build browser-approval URLs the // human opens. When set (e.g. a public-internet self-host behind a reverse // proxy) it is preferred over the request URL — whose host would be the // internal bind address (127.0.0.1:PORT), unreachable for the user. Omit it on // loopback hosts (local/desktop), where the request URL is already correct. options: { readonly webBaseUrl?: string } = {}, ): InMemoryMcpSessionStore => { const transports = new Map(); const servers = new Map(); const owners = new Map(); const engines = new Map>(); const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { const transport = transports.get(id); const server = servers.get(id); transports.delete(id); servers.delete(id); owners.delete(id); engines.delete(id); if (opts.transport) await ignoreClose(transport ? () => transport.close() : undefined); if (opts.server) await ignoreClose(server ? () => server.close() : undefined); }; /** * Drive a transport for one web request, recovering any defect to a 500. On a * fresh transport that never minted a session id (e.g. a non-initialize first * request), close it and its server eagerly so they don't leak. */ const runHandleRequest = ( transport: WebStandardStreamableHTTPServerTransport, request: Request, onClose?: () => void, ): Effect.Effect => { const finish = (): void => { if (onClose && !transport.sessionId) onClose(); }; return Effect.promise(() => transport.handleRequest(request)).pipe( Effect.tap(() => Effect.sync(finish)), Effect.catchCause((cause) => Effect.sync(() => { console.error("[mcp] handleRequest error:", formatBoundaryError(cause)); finish(); return jsonRpcError(500, -32603, "Internal server error"); }), ), ); }; /** Forward to an existing session, enforcing ownership against the principal. */ const forward = ( sessionId: string, principal: Principal, resource: McpResource, request: Request, ): Effect.Effect => { const transport = transports.get(sessionId); const owner = owners.get(sessionId); if (!transport || !owner) return Effect.succeed("not-found"); if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); return runHandleRequest(transport, request); }; /** * The browser-mode wiring for a create request: when the client asks for * `elicitation_mode=browser`, build the server with an `approvalUrl` (anchored * at the request origin + the session id, minted on initialize) and the shared * approval store. Otherwise pass the bare model/native mode through. */ const buildOptionsFor = ( request: Request, sessionId: () => string | null, ): McpBuildServerOptions => { const artifactsEnabled = readArtifactsEnabled(request); const mode = readElicitationMode(request); if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } }; return { artifactsEnabled, elicitationMode: { mode: "browser", // Prefer the pinned public origin; fall back to the request URL (correct // for loopback hosts, the internal bind address behind a proxy). approvalUrl: (executionId) => buildResumeApprovalUrl({ origin: options.webBaseUrl ?? request.url, executionId, sessionId: sessionId(), }), }, browserApprovalStore: approvals.store, }; }; /** Open a new session: build the server, connect a transport, drive the request. */ const create = ( principal: Principal, resource: McpResource, request: Request, ): Effect.Effect => { let createdSessionId: string | null = null; return buildServer(principal, { ...buildOptionsFor(request, () => createdSessionId), resource, }).pipe( Effect.flatMap(({ mcpServer, engine }) => Effect.gen(function* () { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, onsessioninitialized: (sid) => { createdSessionId = sid; transports.set(sid, transport); servers.set(sid, mcpServer); owners.set(sid, { principal, resource }); engines.set(sid, engine); }, onsessionclosed: (sid) => void dispose(sid, { server: true }), }); transport.onclose = () => { const sid = transport.sessionId; if (sid) void dispose(sid, { server: true }); }; yield* Effect.promise(() => mcpServer.connect(transport)); // The session id is minted on the first (initialize) request, so we // drive `handleRequest` here; if no id results we close eagerly. return yield* runHandleRequest(transport, request, () => { void ignoreClose(() => transport.close()); void ignoreClose(() => mcpServer.close()); }); }), ), // A build failure has nowhere typed to go in the envelope; render a 500. Effect.catchTag("McpEngineBuildError", () => Effect.succeed(jsonRpcError(500, -32603, "Internal server error")), ), ); }; const store: McpSessionStore["Service"] = { dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => sessionId ? forward(sessionId, principal, resource, request) : create(principal, resource ?? defaultMcpResource, request), dispose: (sessionId) => Effect.promise(() => dispose(sessionId, { transport: true, server: true })), }; const ownerAccess = ( sessionId: string, principal: Principal | undefined, ): "allowed" | "not-found" | "forbidden" => { const owner = owners.get(sessionId); if (!owner) return "not-found"; if (principal && !principalOwns(owner.principal, principal)) return "forbidden"; return "allowed"; }; /** Resolve a paused execution from the session that owns it, for HTTP approval. */ const pausedFromSession = ( sessionId: string, executionId: string, ): Promise | null> => { const engine = engines.get(sessionId); if (!engine) return Promise.resolve(null); return Effect.runPromise( engine.getPausedExecution(executionId).pipe( Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)), Effect.orElseSucceed(() => null), ), ); }; const handlePausedRequest = async ( request: Request, principal?: Principal, ): Promise => { const match = PAUSED_PATH.exec(new URL(request.url).pathname); if (!match) return null; if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); const sessionId = decodeURIComponent(match[1]!); const access = ownerAccess(sessionId, principal); if (access === "forbidden") return json({ error: "Forbidden" }, 403); if (access === "not-found") return json({ error: "Paused execution not found" }, 404); const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!)); if (!paused) return json({ error: "Paused execution not found" }, 404); return json({ text: paused.text, structured: paused.structured }); }; const handleApprovalRequest = async ( request: Request, principal?: Principal, ): Promise => { const match = RESUME_PATH.exec(new URL(request.url).pathname); if (!match) return null; if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); const sessionId = decodeURIComponent(match[1]!); const executionId = decodeURIComponent(match[2]!); const access = ownerAccess(sessionId, principal); if (access === "forbidden") return json({ error: "Forbidden" }, 403); if (access === "not-found") return json({ error: "Paused execution not found" }, 404); // The session must still hold the paused execution — guards stale ids and // confirms the execution belongs to this session before recording. const paused = await pausedFromSession(sessionId, executionId); if (!paused) return json({ error: "Paused execution not found" }, 404); const raw = await Effect.runPromise( Effect.tryPromise({ try: () => request.json(), catch: () => null }).pipe( Effect.orElseSucceed(() => null), ), ); const response = raw === null ? null : decodeResumeResponse(raw); if (!response) return json({ error: "Invalid approval response" }, 400); await Effect.runPromise(approvals.recordResponse(executionId, response)); return json({ status: "completed", ...formatResumeAcknowledgement(executionId, response), isError: false, }); }; return { store, handlePausedRequest, handleApprovalRequest, close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); }, }; }; /** * Layer wrapping a freshly built in-process store, the `McpSessionStore` * envelope seam. The owning app calls `makeInMemoryMcpSessionStore(buildServer)` * directly so it can wire the `close()` lifetime hook into shutdown, then passes * the built store here. */ export const inMemoryMcpSessionsLayer = ( built: InMemoryMcpSessionStore, ): Layer.Layer => Layer.succeed(McpSessionStore)(built.store);