From 8e3c8bca855470f7521bae890112c5b4c9b2ee7f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Sun, 16 Aug 2026 04:54:47 -0700 Subject: [PATCH] Serve MCP 2026-07-28 clients on selfhost and local HTTP --- apps/host-selfhost/package.json | 1 + apps/host-selfhost/src/app.ts | 7 +- apps/host-selfhost/src/mcp/index.ts | 12 +- apps/host-selfhost/src/mcp/mcp.test.ts | 35 ++++ apps/host-selfhost/src/mcp/session-store.ts | 24 ++- apps/host-selfhost/src/testing/test-app.ts | 2 + apps/local/package.json | 2 + apps/local/src/mcp-modern.test.ts | 54 +++++++ apps/local/src/mcp.ts | 70 +++++++- bun.lock | 3 + packages/core/api/src/server.ts | 2 + packages/core/api/src/server/executor-app.ts | 47 ++++-- packages/core/api/src/server/mcp-build.ts | 54 ++++++- packages/hosts/mcp/src/envelope.test.ts | 145 ++++++++++++++++- packages/hosts/mcp/src/envelope.ts | 161 ++++++++++++++++--- packages/hosts/mcp/src/index.ts | 2 + packages/hosts/mcp/src/seams.ts | 48 +++++- packages/hosts/mcp/src/tool-server-v2.ts | 62 +++++-- 18 files changed, 673 insertions(+), 58 deletions(-) create mode 100644 apps/local/src/mcp-modern.test.ts diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 9a6f70ce13..224b5a3303 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -53,6 +53,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.162.0", diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 4eaf631f54..65f6a2abf6 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -110,7 +110,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // plane's decorator is wired in mcp/session-store.ts's stack layer). decorator: SelfHostAnalyticsEngineDecorator, }, - mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, + mcp: { + auth: mcp.auth, + sessions: mcp.sessions, + modern: mcp.modern, + reporter: mcp.reporter, + }, plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, errorCapture: ErrorCaptureLive, }, diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 52287518cd..0fd9887f30 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server"; import type { McpAuthProvider, McpErrorReporter, + McpModernServerBuilder, McpSessionStore, Principal, } from "@executor-js/host-mcp"; @@ -13,6 +14,7 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, + makeSelfHostMcpModernServerBuilder, selfHostMcpReporter, selfHostMcpSessions, } from "./session-store"; @@ -20,6 +22,7 @@ import { export { selfHostMcpAuth } from "./auth"; export { makeSelfHostMcpSessionStore, + makeSelfHostMcpModernServerBuilder, selfHostMcpReporter, selfHostMcpSessions, McpEngineBuildError, @@ -34,13 +37,15 @@ export { // own auth + session handling and is mounted OUTSIDE the API's execution // middleware, like /api/auth. // -// Self-host provides the TWO envelope seams plus an error-reporter override: +// Self-host provides both era seams plus auth and an error-reporter override: // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still // requires `IdentityProvider`, which `make` provides from // the resolved identity seam. // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns // dispatch (create + forward + ownership) and builds its // engine internally over the shared SelfHostDb. +// - McpModernServerBuilder -> one stateless SDK v2 server per request over +// the same scoped execution stack and tool config. // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the // host's console capture. // @@ -53,6 +58,8 @@ export interface SelfHostMcpSeams { readonly auth: Layer.Layer; /** The in-process session store seam (dispatch + lifetime). */ readonly sessions: Layer.Layer; + /** Stateless SDK v2 server construction for modern requests. */ + readonly modern: Layer.Layer; /** Route 500 defects through the host's console `ErrorCapture`. */ readonly reporter: Layer.Layer; /** @@ -126,7 +133,7 @@ const makeApprovalHandler = * Build the self-host MCP serving seams over the long-lived DB handle. The auth * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth * instance provided; it still requires `IdentityProvider` from the resolved - * identity seam. Returns the three seam Layers plus the `close()` lifetime hook + * identity seam. Returns the four seam Layers plus the `close()` lifetime hook * the app wires into shutdown. */ export const makeSelfHostMcpSeams = ( @@ -141,6 +148,7 @@ export const makeSelfHostMcpSeams = ( return { auth, sessions: selfHostMcpSessions(sessionStore), + modern: makeSelfHostMcpModernServerBuilder(dbHandle), reporter: selfHostMcpReporter, approvalHandler: makeApprovalHandler(sessionStore, betterAuth), close: sessionStore.close, diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 42a9379744..4bda4bbf10 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { mintInviteCode } from "../testing/mint-invite"; @@ -87,6 +88,40 @@ test("an authenticated MCP client initializes, lists tools, and executes code", expect(JSON.stringify(await call.json())).toContain("42"); }); +test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => { + const token = await signUp("modern@mcp.test"); + const seenMethods: string[] = []; + const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), { + fetch: async (input, init) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const body = (await request.clone().json()) as { readonly method?: string }; + if (body.method) seenMethods.push(body.method); + const headers = new Headers(request.headers); + headers.set("authorization", `Bearer ${token}`); + return handler(new Request(request, { headers })); + }, + }); + const client = new Client( + { name: "selfhost-modern-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client + try { + expect(seenMethods).toContain("server/discover"); + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "export default 6 * 7" }, + }); + expect(JSON.stringify(result)).toContain("42"); + } finally { + await client.close(); + } +}); + test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { const alice = await signUp("alice2@mcp.test"); const bob = await signUp("bob2@mcp.test"); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index c17a8fa4db..d41e1e1a95 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -1,7 +1,11 @@ import { Layer } from "effect"; -import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; -import type { McpErrorReporter } from "@executor-js/host-mcp"; +import { + makeConsoleMcpErrorReporter, + makeMcpBuildServer, + makeMcpBuildServerV2, +} from "@executor-js/api/server"; +import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; import { inMemoryMcpSessionsLayer, makeInMemoryMcpSessionStore, @@ -51,6 +55,22 @@ export const makeSelfHostMcpSessionStore = ( { webBaseUrl }, ); +/** Build the stateless SDK v2 server seam over the same self-host stack/config. */ +export const makeSelfHostMcpModernServerBuilder = ( + db: SelfHostDbHandle, +): Layer.Layer => + Layer.succeed(McpModernServerBuilder)({ + build: makeMcpBuildServerV2( + SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + { + loadAppShellHtml: loadMcpAppsShellHtml, + smokeRenderArtifact, + onArtifactUsage: (action) => + selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }), + }, + ), + }); + /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ export const selfHostMcpSessions = inMemoryMcpSessionsLayer; diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index 2261de31c8..480a1e03f2 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -28,6 +28,7 @@ import { import executorConfig from "../../executor.config"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; import { + makeSelfHostMcpModernServerBuilder, makeSelfHostMcpSessionStore, selfHostMcpReporter, selfHostMcpSessions, @@ -236,6 +237,7 @@ export const makeSelfHostTestApp = async ( mcp: { auth: stubMcpAuth, sessions: selfHostMcpSessions(sessionStore), + modern: makeSelfHostMcpModernServerBuilder(dbHandle), reporter: selfHostMcpReporter, }, plugins: { provider: pluginsProvider, config: SelfHostHostConfig }, diff --git a/apps/local/package.json b/apps/local/package.json index 26f877d9b4..b15ed72804 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -47,6 +47,7 @@ "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -55,6 +56,7 @@ "react-dom": "catalog:" }, "devDependencies": { + "@modelcontextprotocol/client": "2.0.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/apps/local/src/mcp-modern.test.ts b/apps/local/src/mcp-modern.test.ts new file mode 100644 index 0000000000..28a2f9c848 --- /dev/null +++ b/apps/local/src/mcp-modern.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { Effect } from "effect"; + +import type { ExecutionEngine } from "@executor-js/execution"; + +import { createMcpRequestHandler } from "./mcp"; + +const engine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: (code) => + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("local modern MCP test executor"), +}; + +describe("local modern MCP HTTP", () => { + it("discovers, lists tools, and executes without creating a legacy session", async () => { + const mcp = createMcpRequestHandler({ engine }); + const sessionHeaders: Array = []; + const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { + fetch: async (input, init) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const response = await mcp.handleRequest(request); + sessionHeaders.push(response.headers.get("mcp-session-id")); + return response; + }, + }); + const client = new Client( + { name: "local-modern-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler + try { + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "2 + 2" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]); + expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true); + } finally { + await client.close(); + await mcp.close(); + } + }); +}); diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index 780782c19c..d811b4f32f 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -1,4 +1,9 @@ import { Effect, type Cause } from "effect"; +import { + createMcpHandler, + isLegacyRequest, + type McpHttpHandler, +} from "@modelcontextprotocol/server"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -13,6 +18,12 @@ import { createExecutorMcpServer, type ExecutorMcpServerConfig, } from "@executor-js/host-mcp/tool-server"; +import { + appsEnabledForClientCapabilities, + buildMcpServerV2, + clientCapabilitiesFromRequest, + requestBodyFromRequest, +} from "@executor-js/host-mcp/tool-server-v2"; import { approvalUrlForRequest, decodeResumeResponse, @@ -129,8 +140,13 @@ export const createMcpRequestHandler = ( const resources = new Map(); const sessionEngines = new Map(); const sessionClosers = new Map Promise>(); + const modernHandlers = new Map(); const approvals = makeInProcessBrowserApprovalStore(); const defaultEngine = engineFromConfig(handlerConfig.defaultConfig); + let requestStateSigningKey: Uint8Array | undefined; + + const signingKey = (): Uint8Array => + (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32))); const pausedDetail = ( sessionId: string, @@ -164,10 +180,58 @@ export const createMcpRequestHandler = ( await ignoreClose(close); }; + const modernHandlerFor = (resource: McpResource): McpHttpHandler => { + const key = mcpResourceKey(resource); + const cached = modernHandlers.get(key); + if (cached) return cached; + + const handler = createMcpHandler( + (context) => { + const request = context.requestInfo; + if (!request) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect + return Effect.runPromise(Effect.die("Modern MCP request context has no request")); + } + return Effect.runPromise( + Effect.gen(function* () { + const resourceConfig = yield* Effect.promise(() => configForResource(resource)); + const clientCapabilities = yield* clientCapabilitiesFromRequest(request); + const server = yield* buildMcpServerV2({ + ...resourceConfig.config, + artifactsEnabled: readArtifactsEnabled(request), + appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), + requestStateSigningKey: signingKey(), + requestStatePrincipal: "local", + }); + if (resourceConfig.close) { + const closeServer = server.close.bind(server); + const closeConfig = resourceConfig.close; + let closed = false; + server.close = async () => { + if (closed) return; + closed = true; + await ignoreClose(closeServer); + await ignoreClose(closeConfig); + }; + } + return server; + }), + ); + }, + { legacy: "reject" }, + ); + modernHandlers.set(key, handler); + return handler; + }; + return { handleRequest: async (request) => { const resource = resourceFromRequest(request); if (!resource) return jsonError(404, -32001, "MCP resource not found"); + if (!(await isLegacyRequest(request))) { + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + return modernHandlerFor(resource).fetch(request, { parsedBody }); + } const sessionId = request.headers.get("mcp-session-id"); if (sessionId) { @@ -283,7 +347,10 @@ export const createMcpRequestHandler = ( close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); - await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); + await Promise.all([ + ...[...ids].map((id) => dispose(id, { transport: true, server: true })), + ...[...modernHandlers.values()].map((handler) => handler.close()), + ]); }, }; }; @@ -295,6 +362,7 @@ export const createMcpRequestHandler = ( export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise => { startIntegrationsRefresh(); + // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy. const server = await Effect.runPromise(createExecutorMcpServer(config)); const transport = new StdioServerTransport(); diff --git a/bun.lock b/bun.lock index 275ea50da3..f48fc7e9cd 100644 --- a/bun.lock +++ b/bun.lock @@ -257,6 +257,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.162.0", @@ -301,6 +302,7 @@ "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,6 +311,7 @@ "react-dom": "catalog:", }, "devDependencies": { + "@modelcontextprotocol/client": "2.0.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c39..32ad323d45 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -58,7 +58,9 @@ export { } from "./server/execution-stack"; export { makeMcpBuildServer, + makeMcpBuildServerV2, makeConsoleMcpErrorReporter, + type McpBuildServerV2, type McpExecutionStackLayer, } from "./server/mcp-build"; // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 382e72efe1..b0390d46a8 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -15,8 +15,8 @@ // + that stack + plugin tuple + failure strategy) (auth + per-request executor) // 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture, // router: prefixed(mountPrefix) }) wrapped by (2) -// 4. the MCP serving envelope = McpServingRoutes + the 2-3 seams (auth/sessions -// /reporter), double-provided like the host did (the seams) +// 4. the MCP serving envelope = McpServingRoutes + the auth/session/modern +// builder/reporter seams (the seams) // 5. the account API = makeAccountApiLayer(accountMiddleware, { router }) // 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn) // 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer @@ -53,6 +53,7 @@ import { McpErrorReporterNoop, type McpAuthProvider, type McpErrorReporter, + type McpModernServerBuilder, type McpSessionStore, } from "@executor-js/host-mcp"; @@ -132,19 +133,32 @@ export interface EngineProviders { * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose * MCP plane is a separate credential surface leaves it `never` (cloud). */ -export interface McpProviders { +interface McpProviderBase { /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */ readonly auth: Layer.Layer; - /** - * Owns the entire serving-session lifecycle (in-process Map vs DO). Optional: - * a host that serves `/mcp` transport outside this envelope (the Cloudflare - * Agent bridge) omits it, and only the discovery routes are mounted. - */ - readonly sessions?: Layer.Layer; /** Forward an orchestration defect to the host's capture; default no-op. */ readonly reporter?: Layer.Layer; } +/** MCP providers either serve discovery alone or provide both protocol eras. */ +export type McpProviders = McpProviderBase & + ( + | { + /** + * Owns the legacy serving-session lifecycle (in-process Map vs DO). + */ + readonly sessions: Layer.Layer; + /** Builds one stateless SDK v2 server for each modern request. */ + readonly modern: Layer.Layer; + } + | { + /** Omitted when another platform surface serves `/mcp` transport. */ + readonly sessions?: undefined; + /** Discovery-only providers do not construct modern servers here. */ + readonly modern?: undefined; + } + ); + /** * The provider seams common to BOTH execution models (scoped + fixed): identity, * the optional account API, the optional MCP envelope, and error capture. The @@ -546,12 +560,12 @@ export const make = < : pluginApiLive; // ---- (4) the MCP serving envelope (optional) -------------------------- - // The two providers, by design (mirrors makeSelfHostMcp): + // The serving providers, by design (mirrors makeSelfHostMcp): // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's // build-time `McpAuthProvider` requirement (it registers a GET per // provider-declared discovery path). // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers' - // per-request `Requires` markers (auth + session store + reporter) so the + // per-request `Requires` markers (auth + both eras + reporter) so the // /mcp routes carry no leftover requirements when merged into the router. // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth = // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback; @@ -603,7 +617,7 @@ export const make = < }; /** - * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth + * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides * the complete identity seam ONCE (memoized) and shares it across the build-time * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single @@ -625,10 +639,15 @@ const buildMcpRoutes = ( // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare // Agent bridge), so mount only the auth-declared discovery routes. The discovery // handlers are captured from the auth seam at build, so no per-request seams. - if (!mcp.sessions) { + if (mcp.sessions === undefined) { return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive)); } - const mcpSeams = Layer.mergeAll(mcpAuthLive, mcp.sessions, mcp.reporter ?? McpErrorReporterNoop); + const mcpSeams = Layer.mergeAll( + mcpAuthLive, + mcp.sessions, + mcp.modern, + mcp.reporter ?? McpErrorReporterNoop, + ); return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive)); }; diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3b9302faca..4371267f47 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -1,12 +1,17 @@ import { Effect, Layer } from "effect"; -import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; +import { + McpErrorReporter, + type McpModernServerBuilder, + type Principal, +} from "@executor-js/host-mcp"; import { McpEngineBuildError, type McpBuildServer, type McpBuildServerOptions, } from "@executor-js/host-mcp/in-memory-session-store"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; import { artifactUrlFor, type ArtifactSmokeRenderResult, @@ -90,6 +95,53 @@ export const makeMcpBuildServer = ), ); +/** Build function consumed by the neutral envelope's modern-server seam. */ +export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"]; + +/** + * Build the per-request SDK v2 server factory over the same execution stack + * and host configuration used by {@link makeMcpBuildServer}. + */ +export const makeMcpBuildServerV2 = + (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServerV2 => + (principal, options) => { + const { resource, ...requestOptions } = options; + return Effect.gen(function* () { + const { engine, executor } = yield* makeExecutionStack( + principal.accountId, + principal.organizationId, + principal.organizationName, + { mcpResource: resource }, + ).pipe(Effect.withSpan("mcp.execution_stack.build")); + const hostConfig = yield* HostConfig; + return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; + }).pipe( + principal.organizationSlug !== undefined + ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) + : (effect) => effect, + Effect.provide(executionStack), + Effect.mapError((cause) => new McpEngineBuildError({ cause })), + Effect.flatMap(({ engine, executor, webBaseUrl }) => + buildMcpServerV2({ + engine, + artifacts: executor.artifacts, + connections: executor.connections, + ...(hostOptions?.loadAppShellHtml + ? { loadAppShellHtml: hostOptions.loadAppShellHtml } + : {}), + ...(hostOptions?.smokeRenderArtifact + ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact } + : {}), + ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}), + ...(webBaseUrl + ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } + : {}), + ...requestOptions, + }).pipe(Effect.withSpan("mcp.server.create")), + ), + ); + }; + /** Per-host (not per-session) MCP wiring. Kept separate from * `McpBuildServerOptions`, which the session store fills in per request. */ export interface McpBuildHostOptions { diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 523dc60a0b..11971edb0c 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -10,6 +10,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { Cause, Effect, Layer, Ref } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; @@ -19,13 +20,18 @@ import { McpAuthProvider, McpErrorReporter, McpErrorReporterNoop, + McpModernServerBuilder, McpServingRoutes, McpDiscoveryRoutes, McpSessionStore, + unauthorized, type McpResource, type McpDispatchResult, type Principal, } from "./index"; +import type { ExecutionEngine } from "@executor-js/execution"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; +import { buildMcpServerV2 } from "./tool-server-v2"; const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; @@ -39,6 +45,25 @@ const TEST_PRINCIPAL: Principal = { roles: ["user"], }; +const testEngine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: (code) => + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("envelope test executor"), +}; + +const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({ + build: (_principal, options) => { + const { resource: _resource, ...requestOptions } = options; + return buildMcpServerV2({ engine: testEngine, ...requestOptions }); + }, +}); + /** An auth provider that authenticates everything (so dispatch is reached). */ const AuthProviderLive = Layer.succeed(McpAuthProvider)({ discoveryRoutes: [ @@ -68,8 +93,9 @@ const buildHandler = ( store: Layer.Layer, reporter: Layer.Layer, authProvider: Layer.Layer = AuthProviderLive, + modernBuilder: Layer.Layer = ModernBuilderLive, ): ((request: Request) => Promise) => { - const Seams = Layer.mergeAll(authProvider, store, reporter); + const Seams = Layer.mergeAll(authProvider, store, modernBuilder, reporter); const RouteLive = McpServingRoutes.pipe( HttpRouter.provideRequest(Seams), Layer.provide(authProvider), @@ -114,7 +140,101 @@ describe("McpServingRoutes envelope", () => { expect(response.status).toBe(204); expect(response.headers.get("access-control-allow-origin")).toBe("*"); expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS"); - expect(response.headers.get("access-control-allow-headers") ?? "").toContain("authorization"); + const allowedHeaders = response.headers.get("access-control-allow-headers") ?? ""; + expect(allowedHeaders).toContain("authorization"); + expect(allowedHeaders).toContain("mcp-method"); + expect(allowedHeaders).toContain("mcp-name"); + }); + + it("echoes requested preflight headers so dynamic Mcp-Param names pass", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + const requested = "content-type, authorization, mcp-protocol-version, mcp-param-search"; + const response = await handler( + new Request("https://host.test/mcp", { + method: "OPTIONS", + headers: { + origin: "https://claude.ai", + "access-control-request-method": "POST", + "access-control-request-headers": requested, + }, + }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-headers")).toBe(requested); + }); + + it("serves modern list/call traffic without dispatching a legacy session", async () => { + const legacyDispatches = await Effect.runPromise(Ref.make(0)); + const appsEnabled = await Effect.runPromise(Ref.make(false)); + const RecordingStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: () => + Ref.update(legacyDispatches, (count) => count + 1).pipe(Effect.as("not-found")), + dispose: () => Effect.void, + }); + const RecordingModernBuilder = Layer.succeed(McpModernServerBuilder)({ + build: (_principal, options) => { + const { resource: _resource, ...requestOptions } = options; + return Ref.set(appsEnabled, options.appsEnabled).pipe( + Effect.flatMap(() => buildMcpServerV2({ engine: testEngine, ...requestOptions })), + ); + }, + }); + const handler = buildHandler( + RecordingStoreLive, + McpErrorReporterNoop, + AuthProviderLive, + RecordingModernBuilder, + ); + const transport = new StreamableHTTPClientTransport(new URL("https://host.test/mcp"), { + fetch: (input, init) => + handler( + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), + ), + }); + const client = new Client( + { name: "envelope-modern-test", version: "1.0.0" }, + { + capabilities: { + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, + }, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the in-process modern client + try { + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "1 + 1" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]); + expect(await Effect.runPromise(Ref.get(legacyDispatches))).toBe(0); + expect(await Effect.runPromise(Ref.get(appsEnabled))).toBe(true); + } finally { + await client.close(); + } + }); + + it("returns the existing 401 challenge before routing a modern request", async () => { + const challenge = 'Bearer resource_metadata="https://host.test/custom-metadata"'; + const UnauthorizedAuthProviderLive = Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: () => "https://host.test/custom-metadata", + authenticate: () => Effect.succeed(unauthorized(challenge)), + }); + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop, UnauthorizedAuthProviderLive); + const response = await handler(modernRequest("https://host.test/mcp")); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe(challenge); + }); + + it("404s a modern request whose toolkit route is not served", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra")); + expect(response.status).toBe(404); }); it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => { @@ -178,6 +298,27 @@ describe("McpServingRoutes envelope", () => { }); }); +const modernRequest = (url: string): Request => + new Request(url, { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "server/discover", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { const seen = await Effect.runPromise(Ref.make(null)); const RecordingStoreLive = Layer.succeed(McpSessionStore)({ diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index fe5483978e..3b04823fb5 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -1,15 +1,29 @@ import { Effect, Match, Predicate } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { + createMcpHandler, + isLegacyRequest, + type McpHttpHandler, + type McpRequestContext, +} from "@modelcontextprotocol/server"; import { defaultMcpResource, McpAuthProvider, McpErrorReporter, + McpModernServerBuilder, McpSessionStore, + mcpResourceKey, type AuthOutcome, type McpDispatchResult, type McpResource, + type Principal, } from "./seams"; +import { + appsEnabledForClientCapabilities, + clientCapabilitiesFromRequest, + requestBodyFromRequest, +} from "./tool-server-v2"; // --------------------------------------------------------------------------- // Provider-neutral MCP serving envelope. @@ -30,7 +44,7 @@ import { // 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. +// comes from the three seams. // // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport @@ -42,6 +56,14 @@ import { const MCP_PATH = "/mcp"; const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; +// Static fallback only: the 2026-07-28 era mirrors request params into +// dynamic `Mcp-Param-` headers (SEP-2243), and CORS header names never +// glob — the preflight must echo `Access-Control-Request-Headers` verbatim to +// admit them. `*` would not help either: it is ignored for credentialed +// requests and never covers `Authorization`. +const MCP_CORS_ALLOWED_HEADERS = + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; +const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate"; /** The methods the streamable-HTTP transport accepts on `/mcp`. */ const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); @@ -68,15 +90,19 @@ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerRespo * 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 => +const corsPreflightResponse = (requestedHeaders?: string | null): Response => new Response(null, { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + // Echo the browser's requested headers so dynamic `Mcp-Param-` + // names pass; the static list is the no-preflight-header fallback. "access-control-allow-headers": - "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", + requestedHeaders && requestedHeaders.trim() !== "" + ? requestedHeaders + : MCP_CORS_ALLOWED_HEADERS, + "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS, }, }); @@ -212,8 +238,85 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => ? jsonRpcResponse(404, -32001, "Session not found") : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); +const withModernMcpCors = (response: Response): Response => { + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +interface ModernRequestInputs { + readonly builder: McpModernServerBuilder["Service"]; + readonly principal: Principal; +} + +interface ModernMcpRouter { + readonly fetch: ( + request: Request, + principal: Principal, + resource: McpResource, + builder: McpModernServerBuilder["Service"], + ) => Promise; +} + +const requestStatePrincipal = (principal: Principal): string => + `${principal.accountId}\u0000${principal.organizationId}`; + +/** Build the resource-keyed, process-lifetime modern handler cache. */ +const makeModernMcpRouter = (): ModernMcpRouter => { + const handlers = new Map(); + const requestInputs = new WeakMap(); + let signingKey: Uint8Array | undefined; + + const getSigningKey = (): Uint8Array => + (signingKey ??= crypto.getRandomValues(new Uint8Array(32))); + + const handlerFor = (resource: McpResource): McpHttpHandler => { + const key = mcpResourceKey(resource); + const cached = handlers.get(key); + if (cached) return cached; + + const handler = createMcpHandler( + (context: McpRequestContext) => { + const request = context.requestInfo; + const inputs = request ? requestInputs.get(request) : undefined; + if (!request || !inputs) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect + return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context")); + } + return Effect.runPromise( + Effect.gen(function* () { + const clientCapabilities = yield* clientCapabilitiesFromRequest(request); + return yield* inputs.builder.build(inputs.principal, { + resource, + appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), + requestStateSigningKey: getSigningKey(), + requestStatePrincipal: requestStatePrincipal(inputs.principal), + }); + }), + ); + }, + { legacy: "reject" }, + ); + handlers.set(key, handler); + return handler; + }; + + return { + fetch: async (request, principal, resource, builder) => { + requestInputs.set(request, { builder, principal }); + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + return handlerFor(resource).fetch(request, { parsedBody }); + }, + }; +}; + /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ -const mcpDispatch = (resource: McpResource) => +const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => Effect.gen(function* () { const httpRequest = yield* HttpServerRequest.HttpServerRequest; const auth = yield* McpAuthProvider; @@ -222,7 +325,9 @@ const mcpDispatch = (resource: McpResource) => // CORS preflight: answer before auth so unauthenticated clients can probe. if (request.method === "OPTIONS") { - return fromWebResponse(corsPreflightResponse()); + return fromWebResponse( + corsPreflightResponse(request.headers.get("access-control-request-headers")), + ); } // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other @@ -245,6 +350,14 @@ const mcpDispatch = (resource: McpResource) => } const principal = outcome.principal; + if (!(yield* Effect.promise(() => isLegacyRequest(request)))) { + const builder = yield* McpModernServerBuilder; + const response = yield* Effect.promise(() => + modern.fetch(request, principal, resource, builder), + ); + return fromWebResponse(withModernMcpCors(response)); + } + // 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 @@ -280,8 +393,8 @@ const mcpDispatch = (resource: McpResource) => * 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( +const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) => + mcpDispatch(resource, modern).pipe( Effect.catchCause((cause) => Effect.gen(function* () { const reporter = yield* McpErrorReporter; @@ -291,15 +404,16 @@ const mcpRoute = (resource: McpResource) => ), ); -const toolkitMcpRoute = Effect.gen(function* () { - const params = yield* HttpRouter.params; - const slug = params.toolkitSlug; - return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); -}); +const toolkitMcpRoute = (modern: ModernMcpRouter) => + Effect.gen(function* () { + const params = yield* HttpRouter.params; + const slug = params.toolkitSlug; + return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource, modern); + }); /** * 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 + * this with its other routes and provides the three seam Layers + the HTTP * platform services. Provider-neutral: cloud adopts the same Layer next. * * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so @@ -310,16 +424,22 @@ const toolkitMcpRoute = Effect.gen(function* () { export const McpServingRoutes = HttpRouter.use((router) => Effect.gen(function* () { const auth = yield* McpAuthProvider; + const modern = makeModernMcpRouter(); 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())), + Effect.gen(function* () { + const preflight = yield* HttpServerRequest.HttpServerRequest; + return fromWebResponse( + corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null), + ); + }), ); } - yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); - yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); + yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource, modern)); + yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute(modern)); }), ); @@ -341,7 +461,12 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) => yield* router.add( "OPTIONS", route.path, - Effect.sync(() => fromWebResponse(corsPreflightResponse())), + Effect.gen(function* () { + const preflight = yield* HttpServerRequest.HttpServerRequest; + return fromWebResponse( + corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null), + ); + }), ); } }), diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index 2e536296d9..8f94d9b8fa 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -16,6 +16,7 @@ export { Principal, McpAuthProvider, McpSessionStore, + McpModernServerBuilder, McpErrorReporter, McpErrorReporterNoop, defaultMcpResource, @@ -33,6 +34,7 @@ export { type McpDiscoveryRoute, type McpDispatchInput, type McpDispatchResult, + type McpModernServerBuildOptions, type McpResource, } from "./seams"; diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 12e713dd91..a52811ed57 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -1,10 +1,11 @@ import { Context, Effect, Layer, Schema } from "effect"; import type { Cause } from "effect"; +import type { McpServer } from "@modelcontextprotocol/server"; // --------------------------------------------------------------------------- // Provider-neutral MCP serving seams. // -// The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO +// The shared MCP serving envelope (see `./envelope`) depends ONLY on these THREE // seams. Each product (self-host, cloud, local) provides its own Layer // satisfying the same tags; the envelope never changes. The seams are kept // deliberately small — anything provider-specific (Durable-Object trace @@ -12,17 +13,18 @@ import type { Cause } from "effect"; // per-org engine construction) is configured *inside* a provider's adapter and // never baked into the envelope. // -// Two seams, deliberately: +// Three seams, deliberately: // 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize // (it may read the `mcp-session-id` header to do session-aware org-authz). // 2. McpSessionStore — owns the serving session lifecycle: create + forward + // ownership, end to end, via a single `dispatch`. The store builds/forwards // the transport and returns the transport `Response`. +// 3. McpModernServerBuilder — builds one stateless SDK v2 server for each +// authenticated modern request. The envelope owns handler/bus lifetime. // -// There is deliberately NO envelope-level engine seam. Self-host's in-process -// store builds its engine via an INTERNAL dependency (its Layer provides it); -// cloud's Durable-Object store builds its engine inside the DO. The engine is a -// store implementation detail, not an envelope seam. +// There is deliberately NO envelope-level engine seam. Both server builders +// remain host adapters; the envelope only chooses the protocol era and supplies +// request/resource/auth context. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -271,7 +273,39 @@ export class McpSessionStore extends Context.Service< >()("@executor-js/host-mcp/McpSessionStore") {} // =========================================================================== -// SEAM 3 (optional) — McpErrorReporter: observe a request-orchestration defect. +// SEAM 3 — McpModernServerBuilder: one stateless SDK v2 server per request. +// =========================================================================== + +/** Request-scoped inputs the envelope adds to a host's modern server config. */ +export interface McpModernServerBuildOptions { + /** The served endpoint whose capability policy the server must apply. */ + readonly resource: McpResource; + /** Whether this request's client advertised MCP Apps HTML support. */ + readonly appsEnabled: boolean; + /** Process-lifetime key used to sign opaque request continuation state. */ + readonly requestStateSigningKey: Uint8Array | string; + /** Stable authenticated-owner key bound into signed continuation state. */ + readonly requestStatePrincipal: string; +} + +/** + * Build one stateless SDK v2 server for an authenticated modern request. + * + * The envelope owns the cached `createMcpHandler` and its subscriptions bus; + * providers own execution-stack construction and tool configuration here. + */ +export class McpModernServerBuilder extends Context.Service< + McpModernServerBuilder, + { + readonly build: ( + principal: Principal, + options: McpModernServerBuildOptions, + ) => Effect.Effect; + } +>()("@executor-js/host-mcp/McpModernServerBuilder") {} + +// =========================================================================== +// SEAM 4 (optional) — McpErrorReporter: observe a request-orchestration defect. // // The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and // renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the diff --git a/packages/hosts/mcp/src/tool-server-v2.ts b/packages/hosts/mcp/src/tool-server-v2.ts index a95233715f..ef8984f39f 100644 --- a/packages/hosts/mcp/src/tool-server-v2.ts +++ b/packages/hosts/mcp/src/tool-server-v2.ts @@ -1,18 +1,18 @@ /** * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era. * - * A later host PR will call {@link buildMcpServerV2} from a - * `createMcpHandler` `McpServerFactory`, once per request. The factory's - * `McpRequestContext.requestInfo` exposes the original HTTP request; the host - * reads its modern `_meta` envelope, extracts `CLIENT_CAPABILITIES_META_KEY`, - * and passes the request-scoped {@link appsEnabledForClientCapabilities} - * decision here. This package deliberately does not replace existing v1 host - * routing. + * Neutral hosts call {@link buildMcpServerV2} from a `createMcpHandler` + * `McpServerFactory`, once per request. The factory's + * `McpRequestContext.requestInfo` exposes the original HTTP request, which + * {@link clientCapabilitiesFromRequest} parses for the request-scoped + * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a + * separate host path. */ import { Effect, Match, Option, Schema } from "effect"; import * as Cause from "effect/Cause"; import { acceptedContent, + CLIENT_CAPABILITIES_META_KEY, createRequestStateCodec, fromJsonSchema, inputRequired, @@ -28,6 +28,7 @@ import type { ElicitationRequest } from "@executor-js/sdk"; import { getUiCapability, + EXTENSION_ID, registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, @@ -79,15 +80,56 @@ export const appsEnabledForClientCapabilities = ( clientCapabilities: McpAppsClientCapabilities | null | undefined, ): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const clientCapabilitiesFromUnknown = (body: unknown): McpAppsClientCapabilities | null => { + if (!isRecord(body)) return null; + const params = body.params; + if (!isRecord(params)) return null; + const metadata = params._meta; + if (!isRecord(metadata)) return null; + const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY]; + if (!isRecord(capabilities)) return null; + const extensions = capabilities.extensions; + if (!isRecord(extensions)) return null; + const ui = extensions[EXTENSION_ID]; + if (!isRecord(ui)) return null; + const mimeTypes = ui.mimeTypes; + if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } }; + if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) { + return null; + } + return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; +}; + +/** Parse a cloned HTTP request body without consuming the request itself. */ +export const requestBodyFromRequest = (request: Request): Effect.Effect => + Effect.tryPromise({ + try: () => request.clone().json(), + catch: () => null, + }).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (body) => body, + }), + ); + +/** + * Parse the MCP Apps capability subset from a modern request's `_meta` + * envelope without consuming the request body used by the SDK handler. + */ +export const clientCapabilitiesFromRequest = ( + request: Request, +): Effect.Effect => + requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromUnknown)); + const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ requestId: context.mcpReq.id, ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), serverContext: context, }); -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const appToolMeta = (metadata: Record): McpAppToolMeta | undefined => { const ui = metadata.ui; if (!isRecord(ui)) return undefined;