History

Serve MCP 2026-07-28 clients on selfhost and local HTTP

Rhys Sullivan committed 2 months ago

Commit a035f7a1 parent aedf1a018 files · +673 −58Browse files

Unified · Split

18 files
  1. apps/host-selfhost/package.json +1 −0
  2. apps/host-selfhost/src/app.ts +6 −1
  3. apps/host-selfhost/src/mcp/index.ts +10 −2
  4. apps/host-selfhost/src/mcp/mcp.test.ts +35 −0
  5. apps/host-selfhost/src/mcp/session-store.ts +22 −2
  6. apps/host-selfhost/src/testing/test-app.ts +2 −0
  7. apps/local/package.json +2 −0
  8. apps/local/src/mcp-modern.test.ts +54 −0
  9. apps/local/src/mcp.ts +69 −1
  10. bun.lock +3 −0
  11. packages/core/api/src/server.ts +2 −0
  12. packages/core/api/src/server/executor-app.ts +33 −14
  13. packages/core/api/src/server/mcp-build.ts +53 −1
  14. packages/hosts/mcp/src/envelope.test.ts +143 −2
  15. packages/hosts/mcp/src/envelope.ts +143 −18
  16. packages/hosts/mcp/src/index.ts +2 −0
  17. packages/hosts/mcp/src/seams.ts +41 −7
  18. packages/hosts/mcp/src/tool-server-v2.ts +52 −10
apps/host-selfhost/package.json+1−0
@@ -53,6 +53,7 @@ · 52 unmodified lines
5353 "devDependencies": {
5454 "@effect/vitest": "catalog:",
5555 "@executor-js/vite-plugin": "workspace:*",
56+ "@modelcontextprotocol/client": "2.0.0",
5657 "@tailwindcss/vite": "catalog:",
5758 "@tanstack/router-plugin": "^1.167.12",
5859 "@tanstack/virtual-file-routes": "^1.162.0",
apps/host-selfhost/src/app.ts+6−1
@@ -110,7 +110,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 109 unmodified lines
110110 // plane's decorator is wired in mcp/session-store.ts's stack layer).
111111 decorator: SelfHostAnalyticsEngineDecorator,
112112 },
113− mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
113+ mcp: {
114+ auth: mcp.auth,
115+ sessions: mcp.sessions,
116+ modern: mcp.modern,
117+ reporter: mcp.reporter,
118+ },
114119 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
115120 errorCapture: ErrorCaptureLive,
116121 },
apps/host-selfhost/src/mcp/index.ts+10−2
@@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines
44 import type {
55 McpAuthProvider,
66 McpErrorReporter,
7+ McpModernServerBuilder,
78 McpSessionStore,
89 Principal,
910 } from "@executor-js/host-mcp";
@@ -13,6 +14,7 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; · 3 unmodified lines
1314 import { selfHostMcpAuth } from "./auth";
1415 import {
1516 makeSelfHostMcpSessionStore,
17+ makeSelfHostMcpModernServerBuilder,
1618 selfHostMcpReporter,
1719 selfHostMcpSessions,
1820 } from "./session-store";
@@ -20,6 +22,7 @@ import { · 1 unmodified line
2022 export { selfHostMcpAuth } from "./auth";
2123 export {
2224 makeSelfHostMcpSessionStore,
25+ makeSelfHostMcpModernServerBuilder,
2326 selfHostMcpReporter,
2427 selfHostMcpSessions,
2528 McpEngineBuildError,
@@ -34,13 +37,15 @@ export { · 8 unmodified lines
3437 // own auth + session handling and is mounted OUTSIDE the API's execution
3538 // middleware, like /api/auth.
3639 //
37−// Self-host provides the TWO envelope seams plus an error-reporter override:
40+// Self-host provides both era seams plus auth and an error-reporter override:
3841 // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still
3942 // requires `IdentityProvider`, which `make` provides from
4043 // the resolved identity seam.
4144 // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
4245 // dispatch (create + forward + ownership) and builds its
4346 // engine internally over the shared SelfHostDb.
47+// - McpModernServerBuilder -> one stateless SDK v2 server per request over
48+// the same scoped execution stack and tool config.
4449 // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
4550 // host's console capture.
4651 //
@@ -53,6 +58,8 @@ export interface SelfHostMcpSeams { · 6 unmodified lines
5358 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
5459 /** The in-process session store seam (dispatch + lifetime). */
5560 readonly sessions: Layer.Layer<McpSessionStore>;
61+ /** Stateless SDK v2 server construction for modern requests. */
62+ readonly modern: Layer.Layer<McpModernServerBuilder>;
5663 /** Route 500 defects through the host's console `ErrorCapture`. */
5764 readonly reporter: Layer.Layer<McpErrorReporter>;
5865 /**
@@ -126,7 +133,7 @@ const makeApprovalHandler = · 67 unmodified lines
126133 * Build the self-host MCP serving seams over the long-lived DB handle. The auth
127134 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth
128135 * instance provided; it still requires `IdentityProvider` from the resolved
129− * identity seam. Returns the three seam Layers plus the `close()` lifetime hook
136+ * identity seam. Returns the four seam Layers plus the `close()` lifetime hook
130137 * the app wires into shutdown.
131138 */
132139 export const makeSelfHostMcpSeams = (
@@ -141,6 +148,7 @@ export const makeSelfHostMcpSeams = ( · 8 unmodified lines
141148 return {
142149 auth,
143150 sessions: selfHostMcpSessions(sessionStore),
151+ modern: makeSelfHostMcpModernServerBuilder(dbHandle),
144152 reporter: selfHostMcpReporter,
145153 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
146154 close: sessionStore.close,
apps/host-selfhost/src/mcp/mcp.test.ts+35−0
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; · 2 unmodified lines
33 import { join } from "node:path";
44
55 import { afterAll, expect, test } from "@effect/vitest";
6+import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
67
78 import { mintInviteCode } from "../testing/mint-invite";
89
@@ -87,6 +88,40 @@ test("an authenticated MCP client initializes, lists tools, and executes code", · 78 unmodified lines
8788 expect(JSON.stringify(await call.json())).toContain("42");
8889 });
8990
91+test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => {
92+ const token = await signUp("modern@mcp.test");
93+ const seenMethods: string[] = [];
94+ const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), {
95+ fetch: async (input, init) => {
96+ const request =
97+ input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
98+ const body = (await request.clone().json()) as { readonly method?: string };
99+ if (body.method) seenMethods.push(body.method);
100+ const headers = new Headers(request.headers);
101+ headers.set("authorization", `Bearer ${token}`);
102+ return handler(new Request(request, { headers }));
103+ },
104+ });
105+ const client = new Client(
106+ { name: "selfhost-modern-test", version: "1.0.0" },
107+ { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
108+ );
109+
110+ await client.connect(transport);
111+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client
112+ try {
113+ expect(seenMethods).toContain("server/discover");
114+ expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
115+ const result = await client.callTool({
116+ name: "execute",
117+ arguments: { code: "export default 6 * 7" },
118+ });
119+ expect(JSON.stringify(result)).toContain("42");
120+ } finally {
121+ await client.close();
122+ }
123+});
124+
90125 test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {
91126 const alice = await signUp("alice2@mcp.test");
92127 const bob = await signUp("bob2@mcp.test");
apps/host-selfhost/src/mcp/session-store.ts+22−2
@@ -1,7 +1,11 @@
11 import { Layer } from "effect";
22
3−import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";
4−import type { McpErrorReporter } from "@executor-js/host-mcp";
3+import {
4+ makeConsoleMcpErrorReporter,
5+ makeMcpBuildServer,
6+ makeMcpBuildServerV2,
7+} from "@executor-js/api/server";
8+import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";
59 import {
610 inMemoryMcpSessionsLayer,
711 makeInMemoryMcpSessionStore,
@@ -51,6 +55,22 @@ export const makeSelfHostMcpSessionStore = ( · 43 unmodified lines
5155 { webBaseUrl },
5256 );
5357
58+/** Build the stateless SDK v2 server seam over the same self-host stack/config. */
59+export const makeSelfHostMcpModernServerBuilder = (
60+ db: SelfHostDbHandle,
61+): Layer.Layer<McpModernServerBuilder> =>
62+ Layer.succeed(McpModernServerBuilder)({
63+ build: makeMcpBuildServerV2(
64+ SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
65+ {
66+ loadAppShellHtml: loadMcpAppsShellHtml,
67+ smokeRenderArtifact,
68+ onArtifactUsage: (action) =>
69+ selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }),
70+ },
71+ ),
72+ });
73+
5474 /** The `McpSessionStore` envelope seam over a freshly built in-process store. */
5575 export const selfHostMcpSessions = inMemoryMcpSessionsLayer;
5676
apps/host-selfhost/src/testing/test-app.ts+2−0
@@ -28,6 +28,7 @@ import { · 27 unmodified lines
2828 import executorConfig from "../../executor.config";
2929 import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
3030 import {
31+ makeSelfHostMcpModernServerBuilder,
3132 makeSelfHostMcpSessionStore,
3233 selfHostMcpReporter,
3334 selfHostMcpSessions,
@@ -236,6 +237,7 @@ export const makeSelfHostTestApp = async ( · 202 unmodified lines
236237 mcp: {
237238 auth: stubMcpAuth,
238239 sessions: selfHostMcpSessions(sessionStore),
240+ modern: makeSelfHostMcpModernServerBuilder(dbHandle),
239241 reporter: selfHostMcpReporter,
240242 },
241243 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
apps/local/package.json+2−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
4747 "@executor-js/vite-plugin": "workspace:*",
4848 "@libsql/client": "catalog:",
4949 "@modelcontextprotocol/sdk": "^1.29.0",
50+ "@modelcontextprotocol/server": "2.0.0",
5051 "@tanstack/react-router": "catalog:",
5152 "drizzle-orm": "catalog:",
5253 "effect": "catalog:",
@@ -55,6 +56,7 @@ · 2 unmodified lines
5556 "react-dom": "catalog:"
5657 },
5758 "devDependencies": {
59+ "@modelcontextprotocol/client": "2.0.0",
5860 "@rhyssul/portless": "^0.13.0",
5961 "@tailwindcss/vite": "catalog:",
6062 "@tanstack/router-plugin": "^1.167.12",
apps/local/src/mcp-modern.test.ts+54−0
@@ -0,0 +1,54 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3+import { Effect } from "effect";
4+
5+import type { ExecutionEngine } from "@executor-js/execution";
6+
7+import { createMcpRequestHandler } from "./mcp";
8+
9+const engine: ExecutionEngine = {
10+ execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
11+ executeWithPause: (code) =>
12+ Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
13+ resume: () => Effect.succeed(null),
14+ isExecutionSettled: () => Effect.succeed(false),
15+ getPausedExecution: () => Effect.succeed(null),
16+ pausedExecutionCount: () => Effect.succeed(0),
17+ hasPausedExecutions: () => Effect.succeed(false),
18+ getDescription: Effect.succeed("local modern MCP test executor"),
19+};
20+
21+describe("local modern MCP HTTP", () => {
22+ it("discovers, lists tools, and executes without creating a legacy session", async () => {
23+ const mcp = createMcpRequestHandler({ engine });
24+ const sessionHeaders: Array<string | null> = [];
25+ const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
26+ fetch: async (input, init) => {
27+ const request =
28+ input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
29+ const response = await mcp.handleRequest(request);
30+ sessionHeaders.push(response.headers.get("mcp-session-id"));
31+ return response;
32+ },
33+ });
34+ const client = new Client(
35+ { name: "local-modern-test", version: "1.0.0" },
36+ { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
37+ );
38+
39+ await client.connect(transport);
40+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler
41+ try {
42+ expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
43+ const result = await client.callTool({
44+ name: "execute",
45+ arguments: { code: "2 + 2" },
46+ });
47+ expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]);
48+ expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true);
49+ } finally {
50+ await client.close();
51+ await mcp.close();
52+ }
53+ });
54+});
apps/local/src/mcp.ts+69−1
@@ -1,4 +1,9 @@
11 import { Effect, type Cause } from "effect";
2+import {
3+ createMcpHandler,
4+ isLegacyRequest,
5+ type McpHttpHandler,
6+} from "@modelcontextprotocol/server";
27 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
38 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
49 import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -13,6 +18,12 @@ import { · 8 unmodified lines
1318 createExecutorMcpServer,
1419 type ExecutorMcpServerConfig,
1520 } from "@executor-js/host-mcp/tool-server";
21+import {
22+ appsEnabledForClientCapabilities,
23+ buildMcpServerV2,
24+ clientCapabilitiesFromRequest,
25+ requestBodyFromRequest,
26+} from "@executor-js/host-mcp/tool-server-v2";
1627 import {
1728 approvalUrlForRequest,
1829 decodeResumeResponse,
@@ -129,8 +140,13 @@ export const createMcpRequestHandler = ( · 110 unmodified lines
129140 const resources = new Map<string, McpResource>();
130141 const sessionEngines = new Map<string, AnyExecutionEngine>();
131142 const sessionClosers = new Map<string, () => Promise<void>>();
143+ const modernHandlers = new Map<string, McpHttpHandler>();
132144 const approvals = makeInProcessBrowserApprovalStore();
133145 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
146+ let requestStateSigningKey: Uint8Array | undefined;
147+
148+ const signingKey = (): Uint8Array =>
149+ (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32)));
134150
135151 const pausedDetail = (
136152 sessionId: string,
@@ -164,10 +180,58 @@ export const createMcpRequestHandler = ( · 27 unmodified lines
164180 await ignoreClose(close);
165181 };
166182
183+ const modernHandlerFor = (resource: McpResource): McpHttpHandler => {
184+ const key = mcpResourceKey(resource);
185+ const cached = modernHandlers.get(key);
186+ if (cached) return cached;
187+
188+ const handler = createMcpHandler(
189+ (context) => {
190+ const request = context.requestInfo;
191+ if (!request) {
192+ // 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
193+ return Effect.runPromise(Effect.die("Modern MCP request context has no request"));
194+ }
195+ return Effect.runPromise(
196+ Effect.gen(function* () {
197+ const resourceConfig = yield* Effect.promise(() => configForResource(resource));
198+ const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
199+ const server = yield* buildMcpServerV2({
200+ ...resourceConfig.config,
201+ artifactsEnabled: readArtifactsEnabled(request),
202+ appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
203+ requestStateSigningKey: signingKey(),
204+ requestStatePrincipal: "local",
205+ });
206+ if (resourceConfig.close) {
207+ const closeServer = server.close.bind(server);
208+ const closeConfig = resourceConfig.close;
209+ let closed = false;
210+ server.close = async () => {
211+ if (closed) return;
212+ closed = true;
213+ await ignoreClose(closeServer);
214+ await ignoreClose(closeConfig);
215+ };
216+ }
217+ return server;
218+ }),
219+ );
220+ },
221+ { legacy: "reject" },
222+ );
223+ modernHandlers.set(key, handler);
224+ return handler;
225+ };
226+
167227 return {
168228 handleRequest: async (request) => {
169229 const resource = resourceFromRequest(request);
170230 if (!resource) return jsonError(404, -32001, "MCP resource not found");
231+ if (!(await isLegacyRequest(request))) {
232+ const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
233+ return modernHandlerFor(resource).fetch(request, { parsedBody });
234+ }
171235 const sessionId = request.headers.get("mcp-session-id");
172236
173237 if (sessionId) {
@@ -283,7 +347,10 @@ export const createMcpRequestHandler = ( · 109 unmodified lines
283347
284348 close: async () => {
285349 const ids = new Set([...transports.keys(), ...servers.keys()]);
286− await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
350+ await Promise.all([
351+ ...[...ids].map((id) => dispose(id, { transport: true, server: true })),
352+ ...[...modernHandlers.values()].map((handler) => handler.close()),
353+ ]);
287354 },
288355 };
289356 };
@@ -295,6 +362,7 @@ export const createMcpRequestHandler = ( · 5 unmodified lines
295362 export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
296363 startIntegrationsRefresh();
297364
365+ // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy.
298366 const server = await Effect.runPromise(createExecutorMcpServer(config));
299367 const transport = new StdioServerTransport();
300368
bun.lock+3−0
@@ -257,6 +257,7 @@ · 256 unmodified lines
257257 "devDependencies": {
258258 "@effect/vitest": "catalog:",
259259 "@executor-js/vite-plugin": "workspace:*",
260+ "@modelcontextprotocol/client": "2.0.0",
260261 "@tailwindcss/vite": "catalog:",
261262 "@tanstack/router-plugin": "^1.167.12",
262263 "@tanstack/virtual-file-routes": "^1.162.0",
@@ -301,6 +302,7 @@ · 38 unmodified lines
301302 "@executor-js/vite-plugin": "workspace:*",
302303 "@libsql/client": "catalog:",
303304 "@modelcontextprotocol/sdk": "^1.29.0",
305+ "@modelcontextprotocol/server": "2.0.0",
304306 "@tanstack/react-router": "catalog:",
305307 "drizzle-orm": "catalog:",
306308 "effect": "catalog:",
@@ -309,6 +311,7 @@ · 2 unmodified lines
309311 "react-dom": "catalog:",
310312 },
311313 "devDependencies": {
314+ "@modelcontextprotocol/client": "2.0.0",
312315 "@rhyssul/portless": "^0.13.0",
313316 "@tailwindcss/vite": "catalog:",
314317 "@tanstack/router-plugin": "^1.167.12",
packages/core/api/src/server.ts+2−0
@@ -58,7 +58,9 @@ export { · 57 unmodified lines
5858 } from "./server/execution-stack";
5959 export {
6060 makeMcpBuildServer,
61+ makeMcpBuildServerV2,
6162 makeConsoleMcpErrorReporter,
63+ type McpBuildServerV2,
6264 type McpExecutionStackLayer,
6365 } from "./server/mcp-build";
6466 // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author
packages/core/api/src/server/executor-app.ts+33−14
@@ -15,8 +15,8 @@ · 14 unmodified lines
1515 // + that stack + plugin tuple + failure strategy) (auth + per-request executor)
1616 // 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture,
1717 // router: prefixed(mountPrefix) }) wrapped by (2)
18−// 4. the MCP serving envelope = McpServingRoutes + the 2-3 seams (auth/sessions
19−// /reporter), double-provided like the host did (the seams)
18+// 4. the MCP serving envelope = McpServingRoutes + the auth/session/modern
19+// builder/reporter seams (the seams)
2020 // 5. the account API = makeAccountApiLayer(accountMiddleware, { router })
2121 // 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn)
2222 // 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer
@@ -53,6 +53,7 @@ import { · 30 unmodified lines
5353 McpErrorReporterNoop,
5454 type McpAuthProvider,
5555 type McpErrorReporter,
56+ type McpModernServerBuilder,
5657 type McpSessionStore,
5758 } from "@executor-js/host-mcp";
5859
@@ -132,19 +133,32 @@ export interface EngineProviders<REngine = never> { · 73 unmodified lines
132133 * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose
133134 * MCP plane is a separate credential surface leaves it `never` (cloud).
134135 */
135−export interface McpProviders<RMcpAuth = never> {
136+interface McpProviderBase<RMcpAuth> {
136137 /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */
137138 readonly auth: Layer.Layer<McpAuthProvider, never, RMcpAuth>;
138− /**
139− * Owns the entire serving-session lifecycle (in-process Map vs DO). Optional:
140− * a host that serves `/mcp` transport outside this envelope (the Cloudflare
141− * Agent bridge) omits it, and only the discovery routes are mounted.
142− */
143− readonly sessions?: Layer.Layer<McpSessionStore>;
144139 /** Forward an orchestration defect to the host's capture; default no-op. */
145140 readonly reporter?: Layer.Layer<McpErrorReporter>;
146141 }
147142
143+/** MCP providers either serve discovery alone or provide both protocol eras. */
144+export type McpProviders<RMcpAuth = never> = McpProviderBase<RMcpAuth> &
145+ (
146+ | {
147+ /**
148+ * Owns the legacy serving-session lifecycle (in-process Map vs DO).
149+ */
150+ readonly sessions: Layer.Layer<McpSessionStore>;
151+ /** Builds one stateless SDK v2 server for each modern request. */
152+ readonly modern: Layer.Layer<McpModernServerBuilder>;
153+ }
154+ | {
155+ /** Omitted when another platform surface serves `/mcp` transport. */
156+ readonly sessions?: undefined;
157+ /** Discovery-only providers do not construct modern servers here. */
158+ readonly modern?: undefined;
159+ }
160+ );
161+
148162 /**
149163 * The provider seams common to BOTH execution models (scoped + fixed): identity,
150164 * the optional account API, the optional MCP envelope, and error capture. The
@@ -546,12 +560,12 @@ export const make = < · 395 unmodified lines
546560 : pluginApiLive;
547561
548562 // ---- (4) the MCP serving envelope (optional) --------------------------
549− // The two providers, by design (mirrors makeSelfHostMcp):
563+ // The serving providers, by design (mirrors makeSelfHostMcp):
550564 // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's
551565 // build-time `McpAuthProvider` requirement (it registers a GET per
552566 // provider-declared discovery path).
553567 // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers'
554− // per-request `Requires` markers (auth + session store + reporter) so the
568+ // per-request `Requires` markers (auth + both eras + reporter) so the
555569 // /mcp routes carry no leftover requirements when merged into the router.
556570 // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth =
557571 // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback;
@@ -603,7 +617,7 @@ export const make = < · 45 unmodified lines
603617 };
604618
605619 /**
606− * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth
620+ * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth
607621 * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides
608622 * the complete identity seam ONCE (memoized) and shares it across the build-time
609623 * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single
@@ -625,10 +639,15 @@ const buildMcpRoutes = <RMcpAuth>( · 15 unmodified lines
625639 // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare
626640 // Agent bridge), so mount only the auth-declared discovery routes. The discovery
627641 // handlers are captured from the auth seam at build, so no per-request seams.
628− if (!mcp.sessions) {
642+ if (mcp.sessions === undefined) {
629643 return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive));
630644 }
631− const mcpSeams = Layer.mergeAll(mcpAuthLive, mcp.sessions, mcp.reporter ?? McpErrorReporterNoop);
645+ const mcpSeams = Layer.mergeAll(
646+ mcpAuthLive,
647+ mcp.sessions,
648+ mcp.modern,
649+ mcp.reporter ?? McpErrorReporterNoop,
650+ );
632651 return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive));
633652 };
634653
packages/core/api/src/server/mcp-build.ts+53−1
@@ -1,12 +1,17 @@
11 import { Effect, Layer } from "effect";
22
3−import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";
3+import {
4+ McpErrorReporter,
5+ type McpModernServerBuilder,
6+ type Principal,
7+} from "@executor-js/host-mcp";
48 import {
59 McpEngineBuildError,
610 type McpBuildServer,
711 type McpBuildServerOptions,
812 } from "@executor-js/host-mcp/in-memory-session-store";
913 import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
14+import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
1015 import {
1116 artifactUrlFor,
1217 type ArtifactSmokeRenderResult,
@@ -90,6 +95,53 @@ export const makeMcpBuildServer = · 77 unmodified lines
9095 ),
9196 );
9297
98+/** Build function consumed by the neutral envelope's modern-server seam. */
99+export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"];
100+
101+/**
102+ * Build the per-request SDK v2 server factory over the same execution stack
103+ * and host configuration used by {@link makeMcpBuildServer}.
104+ */
105+export const makeMcpBuildServerV2 =
106+ (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServerV2 =>
107+ (principal, options) => {
108+ const { resource, ...requestOptions } = options;
109+ return Effect.gen(function* () {
110+ const { engine, executor } = yield* makeExecutionStack(
111+ principal.accountId,
112+ principal.organizationId,
113+ principal.organizationName,
114+ { mcpResource: resource },
115+ ).pipe(Effect.withSpan("mcp.execution_stack.build"));
116+ const hostConfig = yield* HostConfig;
117+ return { engine, executor, webBaseUrl: hostConfig.webBaseUrl };
118+ }).pipe(
119+ principal.organizationSlug !== undefined
120+ ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })
121+ : (effect) => effect,
122+ Effect.provide(executionStack),
123+ Effect.mapError((cause) => new McpEngineBuildError({ cause })),
124+ Effect.flatMap(({ engine, executor, webBaseUrl }) =>
125+ buildMcpServerV2({
126+ engine,
127+ artifacts: executor.artifacts,
128+ connections: executor.connections,
129+ ...(hostOptions?.loadAppShellHtml
130+ ? { loadAppShellHtml: hostOptions.loadAppShellHtml }
131+ : {}),
132+ ...(hostOptions?.smokeRenderArtifact
133+ ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact }
134+ : {}),
135+ ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}),
136+ ...(webBaseUrl
137+ ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }
138+ : {}),
139+ ...requestOptions,
140+ }).pipe(Effect.withSpan("mcp.server.create")),
141+ ),
142+ );
143+ };
144+
93145 /** Per-host (not per-session) MCP wiring. Kept separate from
94146 * `McpBuildServerOptions`, which the session store fills in per request. */
95147 export interface McpBuildHostOptions {
packages/hosts/mcp/src/envelope.test.ts+143−2
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 // ---------------------------------------------------------------------------
1111
1212 import { describe, expect, it } from "@effect/vitest";
13+import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
1314 import { Cause, Effect, Layer, Ref } from "effect";
1415 import { HttpRouter, HttpServer } from "effect/unstable/http";
1516
@@ -19,13 +20,18 @@ import { · 3 unmodified lines
1920 McpAuthProvider,
2021 McpErrorReporter,
2122 McpErrorReporterNoop,
23+ McpModernServerBuilder,
2224 McpServingRoutes,
2325 McpDiscoveryRoutes,
2426 McpSessionStore,
27+ unauthorized,
2528 type McpResource,
2629 type McpDispatchResult,
2730 type Principal,
2831 } from "./index";
32+import type { ExecutionEngine } from "@executor-js/execution";
33+import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
34+import { buildMcpServerV2 } from "./tool-server-v2";
2935
3036 const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;
3137
@@ -39,6 +45,25 @@ const TEST_PRINCIPAL: Principal = { · 7 unmodified lines
3945 roles: ["user"],
4046 };
4147
48+const testEngine: ExecutionEngine = {
49+ execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
50+ executeWithPause: (code) =>
51+ Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
52+ resume: () => Effect.succeed(null),
53+ isExecutionSettled: () => Effect.succeed(false),
54+ getPausedExecution: () => Effect.succeed(null),
55+ pausedExecutionCount: () => Effect.succeed(0),
56+ hasPausedExecutions: () => Effect.succeed(false),
57+ getDescription: Effect.succeed("envelope test executor"),
58+};
59+
60+const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({
61+ build: (_principal, options) => {
62+ const { resource: _resource, ...requestOptions } = options;
63+ return buildMcpServerV2({ engine: testEngine, ...requestOptions });
64+ },
65+});
66+
4267 /** An auth provider that authenticates everything (so dispatch is reached). */
4368 const AuthProviderLive = Layer.succeed(McpAuthProvider)({
4469 discoveryRoutes: [
@@ -68,8 +93,9 @@ const buildHandler = ( · 23 unmodified lines
6893 store: Layer.Layer<McpSessionStore>,
6994 reporter: Layer.Layer<McpErrorReporter>,
7095 authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive,
96+ modernBuilder: Layer.Layer<McpModernServerBuilder> = ModernBuilderLive,
7197 ): ((request: Request) => Promise<Response>) => {
72− const Seams = Layer.mergeAll(authProvider, store, reporter);
98+ const Seams = Layer.mergeAll(authProvider, store, modernBuilder, reporter);
7399 const RouteLive = McpServingRoutes.pipe(
74100 HttpRouter.provideRequest(Seams),
75101 Layer.provide(authProvider),
@@ -114,7 +140,101 @@ describe("McpServingRoutes envelope", () => { · 38 unmodified lines
114140 expect(response.status).toBe(204);
115141 expect(response.headers.get("access-control-allow-origin")).toBe("*");
116142 expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS");
117− expect(response.headers.get("access-control-allow-headers") ?? "").toContain("authorization");
143+ const allowedHeaders = response.headers.get("access-control-allow-headers") ?? "";
144+ expect(allowedHeaders).toContain("authorization");
145+ expect(allowedHeaders).toContain("mcp-method");
146+ expect(allowedHeaders).toContain("mcp-name");
147+ });
148+
149+ it("echoes requested preflight headers so dynamic Mcp-Param names pass", async () => {
150+ const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
151+ const requested = "content-type, authorization, mcp-protocol-version, mcp-param-search";
152+ const response = await handler(
153+ new Request("https://host.test/mcp", {
154+ method: "OPTIONS",
155+ headers: {
156+ origin: "https://claude.ai",
157+ "access-control-request-method": "POST",
158+ "access-control-request-headers": requested,
159+ },
160+ }),
161+ );
162+ expect(response.status).toBe(204);
163+ expect(response.headers.get("access-control-allow-headers")).toBe(requested);
164+ });
165+
166+ it("serves modern list/call traffic without dispatching a legacy session", async () => {
167+ const legacyDispatches = await Effect.runPromise(Ref.make(0));
168+ const appsEnabled = await Effect.runPromise(Ref.make(false));
169+ const RecordingStoreLive = Layer.succeed(McpSessionStore)({
170+ dispatch: () =>
171+ Ref.update(legacyDispatches, (count) => count + 1).pipe(Effect.as("not-found")),
172+ dispose: () => Effect.void,
173+ });
174+ const RecordingModernBuilder = Layer.succeed(McpModernServerBuilder)({
175+ build: (_principal, options) => {
176+ const { resource: _resource, ...requestOptions } = options;
177+ return Ref.set(appsEnabled, options.appsEnabled).pipe(
178+ Effect.flatMap(() => buildMcpServerV2({ engine: testEngine, ...requestOptions })),
179+ );
180+ },
181+ });
182+ const handler = buildHandler(
183+ RecordingStoreLive,
184+ McpErrorReporterNoop,
185+ AuthProviderLive,
186+ RecordingModernBuilder,
187+ );
188+ const transport = new StreamableHTTPClientTransport(new URL("https://host.test/mcp"), {
189+ fetch: (input, init) =>
190+ handler(
191+ input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
192+ ),
193+ });
194+ const client = new Client(
195+ { name: "envelope-modern-test", version: "1.0.0" },
196+ {
197+ capabilities: {
198+ extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
199+ },
200+ versionNegotiation: { mode: { pin: "2026-07-28" } },
201+ },
202+ );
203+
204+ await client.connect(transport);
205+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the in-process modern client
206+ try {
207+ expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
208+ const result = await client.callTool({
209+ name: "execute",
210+ arguments: { code: "1 + 1" },
211+ });
212+ expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]);
213+ expect(await Effect.runPromise(Ref.get(legacyDispatches))).toBe(0);
214+ expect(await Effect.runPromise(Ref.get(appsEnabled))).toBe(true);
215+ } finally {
216+ await client.close();
217+ }
218+ });
219+
220+ it("returns the existing 401 challenge before routing a modern request", async () => {
221+ const challenge = 'Bearer resource_metadata="https://host.test/custom-metadata"';
222+ const UnauthorizedAuthProviderLive = Layer.succeed(McpAuthProvider)({
223+ discoveryRoutes: [],
224+ resourceMetadataUrl: () => "https://host.test/custom-metadata",
225+ authenticate: () => Effect.succeed(unauthorized(challenge)),
226+ });
227+ const handler = buildHandler(OkStoreLive, McpErrorReporterNoop, UnauthorizedAuthProviderLive);
228+ const response = await handler(modernRequest("https://host.test/mcp"));
229+
230+ expect(response.status).toBe(401);
231+ expect(response.headers.get("www-authenticate")).toBe(challenge);
232+ });
233+
234+ it("404s a modern request whose toolkit route is not served", async () => {
235+ const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
236+ const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra"));
237+ expect(response.status).toBe(404);
118238 });
119239
120240 it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => {
@@ -178,6 +298,27 @@ describe("McpServingRoutes envelope", () => { · 57 unmodified lines
178298 });
179299 });
180300
301+const modernRequest = (url: string): Request =>
302+ new Request(url, {
303+ method: "POST",
304+ headers: {
305+ "content-type": "application/json",
306+ "mcp-protocol-version": "2026-07-28",
307+ "mcp-method": "server/discover",
308+ },
309+ body: JSON.stringify({
310+ jsonrpc: "2.0",
311+ id: 1,
312+ method: "server/discover",
313+ params: {
314+ _meta: {
315+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
316+ "io.modelcontextprotocol/clientCapabilities": {},
317+ },
318+ },
319+ }),
320+ });
321+
181322 it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => {
182323 const seen = await Effect.runPromise(Ref.make<McpResource | null>(null));
183324 const RecordingStoreLive = Layer.succeed(McpSessionStore)({
packages/hosts/mcp/src/envelope.ts+143−18
@@ -1,15 +1,29 @@
11 import { Effect, Match, Predicate } from "effect";
22 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3+import {
4+ createMcpHandler,
5+ isLegacyRequest,
6+ type McpHttpHandler,
7+ type McpRequestContext,
8+} from "@modelcontextprotocol/server";
39
410 import {
511 defaultMcpResource,
612 McpAuthProvider,
713 McpErrorReporter,
14+ McpModernServerBuilder,
815 McpSessionStore,
16+ mcpResourceKey,
917 type AuthOutcome,
1018 type McpDispatchResult,
1119 type McpResource,
20+ type Principal,
1221 } from "./seams";
22+import {
23+ appsEnabledForClientCapabilities,
24+ clientCapabilitiesFromRequest,
25+ requestBodyFromRequest,
26+} from "./tool-server-v2";
1327
1428 // ---------------------------------------------------------------------------
1529 // Provider-neutral MCP serving envelope.
@@ -30,7 +44,7 @@ import { · 14 unmodified lines
3044 // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else
3145 // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz
3246 // semantics, and the entire session lifecycle (create + forward + ownership) —
33−// comes from the two seams.
47+// comes from the three seams.
3448 //
3549 // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO
3650 // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport
@@ -42,6 +56,14 @@ import { · 5 unmodified lines
4256
4357 const MCP_PATH = "/mcp";
4458 const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
59+// Static fallback only: the 2026-07-28 era mirrors request params into
60+// dynamic `Mcp-Param-<name>` headers (SEP-2243), and CORS header names never
61+// glob — the preflight must echo `Access-Control-Request-Headers` verbatim to
62+// admit them. `*` would not help either: it is ignored for credentialed
63+// requests and never covers `Authorization`.
64+const MCP_CORS_ALLOWED_HEADERS =
65+ "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name";
66+const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate";
4567
4668 /** The methods the streamable-HTTP transport accepts on `/mcp`. */
4769 const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -68,15 +90,19 @@ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerRespo · 20 unmodified lines
6890 * preflight against the metadata docs too (RFC 9728 discovery from a 401), so
6991 * the envelope answers OPTIONS for those paths, not only `/mcp`.
7092 */
71−const corsPreflightResponse = (): Response =>
93+const corsPreflightResponse = (requestedHeaders?: string | null): Response =>
7294 new Response(null, {
7395 status: 204,
7496 headers: {
7597 "access-control-allow-origin": "*",
7698 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
99+ // Echo the browser's requested headers so dynamic `Mcp-Param-<name>`
100+ // names pass; the static list is the no-preflight-header fallback.
77101 "access-control-allow-headers":
78− "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
79− "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
102+ requestedHeaders && requestedHeaders.trim() !== ""
103+ ? requestedHeaders
104+ : MCP_CORS_ALLOWED_HEADERS,
105+ "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS,
80106 },
81107 });
82108
@@ -212,8 +238,85 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 129 unmodified lines
212238 ? jsonRpcResponse(404, -32001, "Session not found")
213239 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
214240
241+const withModernMcpCors = (response: Response): Response => {
242+ const headers = new Headers(response.headers);
243+ headers.set("access-control-allow-origin", "*");
244+ headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS);
245+ return new Response(response.body, {
246+ status: response.status,
247+ statusText: response.statusText,
248+ headers,
249+ });
250+};
251+
252+interface ModernRequestInputs {
253+ readonly builder: McpModernServerBuilder["Service"];
254+ readonly principal: Principal;
255+}
256+
257+interface ModernMcpRouter {
258+ readonly fetch: (
259+ request: Request,
260+ principal: Principal,
261+ resource: McpResource,
262+ builder: McpModernServerBuilder["Service"],
263+ ) => Promise<Response>;
264+}
265+
266+const requestStatePrincipal = (principal: Principal): string =>
267+ `${principal.accountId}\u0000${principal.organizationId}`;
268+
269+/** Build the resource-keyed, process-lifetime modern handler cache. */
270+const makeModernMcpRouter = (): ModernMcpRouter => {
271+ const handlers = new Map<string, McpHttpHandler>();
272+ const requestInputs = new WeakMap<Request, ModernRequestInputs>();
273+ let signingKey: Uint8Array | undefined;
274+
275+ const getSigningKey = (): Uint8Array =>
276+ (signingKey ??= crypto.getRandomValues(new Uint8Array(32)));
277+
278+ const handlerFor = (resource: McpResource): McpHttpHandler => {
279+ const key = mcpResourceKey(resource);
280+ const cached = handlers.get(key);
281+ if (cached) return cached;
282+
283+ const handler = createMcpHandler(
284+ (context: McpRequestContext) => {
285+ const request = context.requestInfo;
286+ const inputs = request ? requestInputs.get(request) : undefined;
287+ if (!request || !inputs) {
288+ // 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
289+ return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context"));
290+ }
291+ return Effect.runPromise(
292+ Effect.gen(function* () {
293+ const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
294+ return yield* inputs.builder.build(inputs.principal, {
295+ resource,
296+ appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
297+ requestStateSigningKey: getSigningKey(),
298+ requestStatePrincipal: requestStatePrincipal(inputs.principal),
299+ });
300+ }),
301+ );
302+ },
303+ { legacy: "reject" },
304+ );
305+ handlers.set(key, handler);
306+ return handler;
307+ };
308+
309+ return {
310+ fetch: async (request, principal, resource, builder) => {
311+ requestInputs.set(request, { builder, principal });
312+ const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
313+ return handlerFor(resource).fetch(request, { parsedBody });
314+ },
315+ };
316+};
317+
215318 /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */
216−const mcpDispatch = (resource: McpResource) =>
319+const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) =>
217320 Effect.gen(function* () {
218321 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
219322 const auth = yield* McpAuthProvider;
@@ -222,7 +325,9 @@ const mcpDispatch = (resource: McpResource) => · 2 unmodified lines
222325
223326 // CORS preflight: answer before auth so unauthenticated clients can probe.
224327 if (request.method === "OPTIONS") {
225− return fromWebResponse(corsPreflightResponse());
328+ return fromWebResponse(
329+ corsPreflightResponse(request.headers.get("access-control-request-headers")),
330+ );
226331 }
227332
228333 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
@@ -245,6 +350,14 @@ const mcpDispatch = (resource: McpResource) => · 16 unmodified lines
245350 }
246351 const principal = outcome.principal;
247352
353+ if (!(yield* Effect.promise(() => isLegacyRequest(request)))) {
354+ const builder = yield* McpModernServerBuilder;
355+ const response = yield* Effect.promise(() =>
356+ modern.fetch(request, principal, resource, builder),
357+ );
358+ return fromWebResponse(withModernMcpCors(response));
359+ }
360+
248361 // No session id: per the streamable-HTTP transport contract, only POST opens
249362 // a session. A GET needs an existing id (400); a DELETE on nothing is a
250363 // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
@@ -280,8 +393,8 @@ const mcpDispatch = (resource: McpResource) => · 29 unmodified lines
280393 * otherwise, since the envelope returns a `Response`) and rendered as a stable
281394 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.
282395 */
283−const mcpRoute = (resource: McpResource) =>
284− mcpDispatch(resource).pipe(
396+const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) =>
397+ mcpDispatch(resource, modern).pipe(
285398 Effect.catchCause((cause) =>
286399 Effect.gen(function* () {
287400 const reporter = yield* McpErrorReporter;
@@ -291,15 +404,16 @@ const mcpRoute = (resource: McpResource) => · 3 unmodified lines
291404 ),
292405 );
293406
294−const toolkitMcpRoute = Effect.gen(function* () {
295− const params = yield* HttpRouter.params;
296− const slug = params.toolkitSlug;
297− return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);
298−});
407+const toolkitMcpRoute = (modern: ModernMcpRouter) =>
408+ Effect.gen(function* () {
409+ const params = yield* HttpRouter.params;
410+ const slug = params.toolkitSlug;
411+ return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource, modern);
412+ });
299413
300414 /**
301415 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges
302− * this with its other routes and provides the two seam Layers + the HTTP
416+ * this with its other routes and provides the three seam Layers + the HTTP
303417 * platform services. Provider-neutral: cloud adopts the same Layer next.
304418 *
305419 * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so
@@ -310,16 +424,22 @@ const toolkitMcpRoute = Effect.gen(function* () { · 4 unmodified lines
310424 export const McpServingRoutes = HttpRouter.use((router) =>
311425 Effect.gen(function* () {
312426 const auth = yield* McpAuthProvider;
427+ const modern = makeModernMcpRouter();
313428 for (const route of auth.discoveryRoutes) {
314429 yield* router.add("GET", route.path, discoveryRoute(route.handler));
315430 yield* router.add(
316431 "OPTIONS",
317432 route.path,
318− Effect.sync(() => fromWebResponse(corsPreflightResponse())),
433+ Effect.gen(function* () {
434+ const preflight = yield* HttpServerRequest.HttpServerRequest;
435+ return fromWebResponse(
436+ corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null),
437+ );
438+ }),
319439 );
320440 }
321− yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
322− yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);
441+ yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource, modern));
442+ yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute(modern));
323443 }),
324444 );
325445
@@ -341,7 +461,12 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) => · 15 unmodified lines
341461 yield* router.add(
342462 "OPTIONS",
343463 route.path,
344− Effect.sync(() => fromWebResponse(corsPreflightResponse())),
464+ Effect.gen(function* () {
465+ const preflight = yield* HttpServerRequest.HttpServerRequest;
466+ return fromWebResponse(
467+ corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null),
468+ );
469+ }),
345470 );
346471 }
347472 }),
packages/hosts/mcp/src/index.ts+2−0
@@ -16,6 +16,7 @@ export { · 15 unmodified lines
1616 Principal,
1717 McpAuthProvider,
1818 McpSessionStore,
19+ McpModernServerBuilder,
1920 McpErrorReporter,
2021 McpErrorReporterNoop,
2122 defaultMcpResource,
@@ -33,6 +34,7 @@ export { · 11 unmodified lines
3334 type McpDiscoveryRoute,
3435 type McpDispatchInput,
3536 type McpDispatchResult,
37+ type McpModernServerBuildOptions,
3638 type McpResource,
3739 } from "./seams";
3840
packages/hosts/mcp/src/seams.ts+41−7
@@ -1,10 +1,11 @@
11 import { Context, Effect, Layer, Schema } from "effect";
22 import type { Cause } from "effect";
3+import type { McpServer } from "@modelcontextprotocol/server";
34
45 // ---------------------------------------------------------------------------
56 // Provider-neutral MCP serving seams.
67 //
7−// The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO
8+// The shared MCP serving envelope (see `./envelope`) depends ONLY on these THREE
89 // seams. Each product (self-host, cloud, local) provides its own Layer
910 // satisfying the same tags; the envelope never changes. The seams are kept
1011 // deliberately small — anything provider-specific (Durable-Object trace
@@ -12,17 +13,18 @@ import type { Cause } from "effect"; · 1 unmodified line
1213 // per-org engine construction) is configured *inside* a provider's adapter and
1314 // never baked into the envelope.
1415 //
15−// Two seams, deliberately:
16+// Three seams, deliberately:
1617 // 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize
1718 // (it may read the `mcp-session-id` header to do session-aware org-authz).
1819 // 2. McpSessionStore — owns the serving session lifecycle: create + forward +
1920 // ownership, end to end, via a single `dispatch`. The store builds/forwards
2021 // the transport and returns the transport `Response`.
22+// 3. McpModernServerBuilder — builds one stateless SDK v2 server for each
23+// authenticated modern request. The envelope owns handler/bus lifetime.
2124 //
22−// There is deliberately NO envelope-level engine seam. Self-host's in-process
23−// store builds its engine via an INTERNAL dependency (its Layer provides it);
24−// cloud's Durable-Object store builds its engine inside the DO. The engine is a
25−// store implementation detail, not an envelope seam.
25+// There is deliberately NO envelope-level engine seam. Both server builders
26+// remain host adapters; the envelope only chooses the protocol era and supplies
27+// request/resource/auth context.
2628 // ---------------------------------------------------------------------------
2729
2830 // ---------------------------------------------------------------------------
@@ -271,7 +273,39 @@ export class McpSessionStore extends Context.Service< · 242 unmodified lines
271273 >()("@executor-js/host-mcp/McpSessionStore") {}
272274
273275 // ===========================================================================
274−// SEAM 3 (optional) — McpErrorReporter: observe a request-orchestration defect.
276+// SEAM 3 — McpModernServerBuilder: one stateless SDK v2 server per request.
277+// ===========================================================================
278+
279+/** Request-scoped inputs the envelope adds to a host's modern server config. */
280+export interface McpModernServerBuildOptions {
281+ /** The served endpoint whose capability policy the server must apply. */
282+ readonly resource: McpResource;
283+ /** Whether this request's client advertised MCP Apps HTML support. */
284+ readonly appsEnabled: boolean;
285+ /** Process-lifetime key used to sign opaque request continuation state. */
286+ readonly requestStateSigningKey: Uint8Array | string;
287+ /** Stable authenticated-owner key bound into signed continuation state. */
288+ readonly requestStatePrincipal: string;
289+}
290+
291+/**
292+ * Build one stateless SDK v2 server for an authenticated modern request.
293+ *
294+ * The envelope owns the cached `createMcpHandler` and its subscriptions bus;
295+ * providers own execution-stack construction and tool configuration here.
296+ */
297+export class McpModernServerBuilder extends Context.Service<
298+ McpModernServerBuilder,
299+ {
300+ readonly build: (
301+ principal: Principal,
302+ options: McpModernServerBuildOptions,
303+ ) => Effect.Effect<McpServer, Cause.YieldableError>;
304+ }
305+>()("@executor-js/host-mcp/McpModernServerBuilder") {}
306+
307+// ===========================================================================
308+// SEAM 4 (optional) — McpErrorReporter: observe a request-orchestration defect.
275309 //
276310 // The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and
277311 // renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the
packages/hosts/mcp/src/tool-server-v2.ts+52−10
@@ -1,18 +1,18 @@
11 /**
22 * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era.
33 *
4− * A later host PR will call {@link buildMcpServerV2} from a
5− * `createMcpHandler` `McpServerFactory`, once per request. The factory's
6− * `McpRequestContext.requestInfo` exposes the original HTTP request; the host
7− * reads its modern `_meta` envelope, extracts `CLIENT_CAPABILITIES_META_KEY`,
8− * and passes the request-scoped {@link appsEnabledForClientCapabilities}
9− * decision here. This package deliberately does not replace existing v1 host
10− * routing.
4+ * Neutral hosts call {@link buildMcpServerV2} from a `createMcpHandler`
5+ * `McpServerFactory`, once per request. The factory's
6+ * `McpRequestContext.requestInfo` exposes the original HTTP request, which
7+ * {@link clientCapabilitiesFromRequest} parses for the request-scoped
8+ * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a
9+ * separate host path.
1110 */
1211 import { Effect, Match, Option, Schema } from "effect";
1312 import * as Cause from "effect/Cause";
1413 import {
1514 acceptedContent,
15+ CLIENT_CAPABILITIES_META_KEY,
1616 createRequestStateCodec,
1717 fromJsonSchema,
1818 inputRequired,
@@ -28,6 +28,7 @@ import type { ElicitationRequest } from "@executor-js/sdk"; · 9 unmodified lines
2828
2929 import {
3030 getUiCapability,
31+ EXTENSION_ID,
3132 registerAppResource,
3233 registerAppTool,
3334 RESOURCE_MIME_TYPE,
@@ -79,15 +80,56 @@ export const appsEnabledForClientCapabilities = ( · 45 unmodified lines
7980 clientCapabilities: McpAppsClientCapabilities | null | undefined,
8081 ): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
8182
83+const isRecord = (value: unknown): value is Record<string, unknown> =>
84+ typeof value === "object" && value !== null && !Array.isArray(value);
85+
86+const clientCapabilitiesFromUnknown = (body: unknown): McpAppsClientCapabilities | null => {
87+ if (!isRecord(body)) return null;
88+ const params = body.params;
89+ if (!isRecord(params)) return null;
90+ const metadata = params._meta;
91+ if (!isRecord(metadata)) return null;
92+ const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY];
93+ if (!isRecord(capabilities)) return null;
94+ const extensions = capabilities.extensions;
95+ if (!isRecord(extensions)) return null;
96+ const ui = extensions[EXTENSION_ID];
97+ if (!isRecord(ui)) return null;
98+ const mimeTypes = ui.mimeTypes;
99+ if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } };
100+ if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) {
101+ return null;
102+ }
103+ return { extensions: { [EXTENSION_ID]: { mimeTypes } } };
104+};
105+
106+/** Parse a cloned HTTP request body without consuming the request itself. */
107+export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>
108+ Effect.tryPromise({
109+ try: () => request.clone().json(),
110+ catch: () => null,
111+ }).pipe(
112+ Effect.match({
113+ onFailure: () => null,
114+ onSuccess: (body) => body,
115+ }),
116+ );
117+
118+/**
119+ * Parse the MCP Apps capability subset from a modern request's `_meta`
120+ * envelope without consuming the request body used by the SDK handler.
121+ */
122+export const clientCapabilitiesFromRequest = (
123+ request: Request,
124+): Effect.Effect<McpAppsClientCapabilities | null> =>
125+ requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromUnknown));
126+
82127 const requestJoinKeys = (context: ServerContext): V2RequestContext => ({
83128 requestId: context.mcpReq.id,
84129 ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
85130 serverContext: context,
86131 });
87132
88−const isRecord = (value: unknown): value is Record<string, unknown> =>
89− typeof value === "object" && value !== null && !Array.isArray(value);
90−
91133 const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {
92134 const ui = metadata.ui;
93135 if (!isRecord(ui)) return undefined;