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
53 "devDependencies": {53 "devDependencies": {
54 "@effect/vitest": "catalog:",54 "@effect/vitest": "catalog:",
55 "@executor-js/vite-plugin": "workspace:*",55 "@executor-js/vite-plugin": "workspace:*",
56 "@modelcontextprotocol/client": "2.0.0",
56 "@tailwindcss/vite": "catalog:",57 "@tailwindcss/vite": "catalog:",
57 "@tanstack/router-plugin": "^1.167.12",58 "@tanstack/router-plugin": "^1.167.12",
58 "@tanstack/virtual-file-routes": "^1.162.0",59 "@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
110 // plane's decorator is wired in mcp/session-store.ts's stack layer).110 // plane's decorator is wired in mcp/session-store.ts's stack layer).
111 decorator: SelfHostAnalyticsEngineDecorator,111 decorator: SelfHostAnalyticsEngineDecorator,
112 },112 },
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 },
114 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },119 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
115 errorCapture: ErrorCaptureLive,120 errorCapture: ErrorCaptureLive,
116 },121 },
apps/host-selfhost/src/mcp/index.ts+10−2
@@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines
4import type {4import type {
5 McpAuthProvider,5 McpAuthProvider,
6 McpErrorReporter,6 McpErrorReporter,
7 McpModernServerBuilder,
7 McpSessionStore,8 McpSessionStore,
8 Principal,9 Principal,
9} from "@executor-js/host-mcp";10} from "@executor-js/host-mcp";
@@ -13,6 +14,7 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; · 3 unmodified lines
13import { selfHostMcpAuth } from "./auth";14import { selfHostMcpAuth } from "./auth";
14import {15import {
15 makeSelfHostMcpSessionStore,16 makeSelfHostMcpSessionStore,
17 makeSelfHostMcpModernServerBuilder,
16 selfHostMcpReporter,18 selfHostMcpReporter,
17 selfHostMcpSessions,19 selfHostMcpSessions,
18} from "./session-store";20} from "./session-store";
@@ -20,6 +22,7 @@ import { · 1 unmodified line
20export { selfHostMcpAuth } from "./auth";22export { selfHostMcpAuth } from "./auth";
21export {23export {
22 makeSelfHostMcpSessionStore,24 makeSelfHostMcpSessionStore,
25 makeSelfHostMcpModernServerBuilder,
23 selfHostMcpReporter,26 selfHostMcpReporter,
24 selfHostMcpSessions,27 selfHostMcpSessions,
25 McpEngineBuildError,28 McpEngineBuildError,
@@ -34,13 +37,15 @@ export { · 8 unmodified lines
34// own auth + session handling and is mounted OUTSIDE the API's execution37// own auth + session handling and is mounted OUTSIDE the API's execution
35// middleware, like /api/auth.38// middleware, like /api/auth.
36//39//
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:
38// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still41// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still
39// requires `IdentityProvider`, which `make` provides from42// requires `IdentityProvider`, which `make` provides from
40// the resolved identity seam.43// the resolved identity seam.
41// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns44// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
42// dispatch (create + forward + ownership) and builds its45// dispatch (create + forward + ownership) and builds its
43// engine internally over the shared SelfHostDb.46// 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.
44// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the49// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
45// host's console capture.50// host's console capture.
46//51//
@@ -53,6 +58,8 @@ export interface SelfHostMcpSeams { · 6 unmodified lines
53 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;58 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
54 /** The in-process session store seam (dispatch + lifetime). */59 /** The in-process session store seam (dispatch + lifetime). */
55 readonly sessions: Layer.Layer<McpSessionStore>;60 readonly sessions: Layer.Layer<McpSessionStore>;
61 /** Stateless SDK v2 server construction for modern requests. */
62 readonly modern: Layer.Layer<McpModernServerBuilder>;
56 /** Route 500 defects through the host's console `ErrorCapture`. */63 /** Route 500 defects through the host's console `ErrorCapture`. */
57 readonly reporter: Layer.Layer<McpErrorReporter>;64 readonly reporter: Layer.Layer<McpErrorReporter>;
58 /**65 /**
@@ -126,7 +133,7 @@ const makeApprovalHandler = · 67 unmodified lines
126 * Build the self-host MCP serving seams over the long-lived DB handle. The auth133 * Build the self-host MCP serving seams over the long-lived DB handle. The auth
127 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth134 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth
128 * instance provided; it still requires `IdentityProvider` from the resolved135 * instance provided; it still requires `IdentityProvider` from the resolved
129 * identity seam. Returns the three seam Layers plus the `close()` lifetime hook136 * identity seam. Returns the four seam Layers plus the `close()` lifetime hook
130 * the app wires into shutdown.137 * the app wires into shutdown.
131 */138 */
132export const makeSelfHostMcpSeams = (139export const makeSelfHostMcpSeams = (
@@ -141,6 +148,7 @@ export const makeSelfHostMcpSeams = ( · 8 unmodified lines
141 return {148 return {
142 auth,149 auth,
143 sessions: selfHostMcpSessions(sessionStore),150 sessions: selfHostMcpSessions(sessionStore),
151 modern: makeSelfHostMcpModernServerBuilder(dbHandle),
144 reporter: selfHostMcpReporter,152 reporter: selfHostMcpReporter,
145 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),153 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
146 close: sessionStore.close,154 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
3import { join } from "node:path";3import { join } from "node:path";
4 4
5import { afterAll, expect, test } from "@effect/vitest";5import { afterAll, expect, test } from "@effect/vitest";
6import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
6 7
7import { mintInviteCode } from "../testing/mint-invite";8import { mintInviteCode } from "../testing/mint-invite";
8 9
@@ -87,6 +88,40 @@ test("an authenticated MCP client initializes, lists tools, and executes code", · 78 unmodified lines
87 expect(JSON.stringify(await call.json())).toContain("42");88 expect(JSON.stringify(await call.json())).toContain("42");
88});89});
89 90
91test("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
90test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {125test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {
91 const alice = await signUp("alice2@mcp.test");126 const alice = await signUp("alice2@mcp.test");
92 const bob = await signUp("bob2@mcp.test");127 const bob = await signUp("bob2@mcp.test");
apps/host-selfhost/src/mcp/session-store.ts+22−2
@@ -1,7 +1,11 @@
1import { Layer } from "effect";1import { Layer } from "effect";
2 2
3import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";3import {
4import type { McpErrorReporter } from "@executor-js/host-mcp";4 makeConsoleMcpErrorReporter,
5 makeMcpBuildServer,
6 makeMcpBuildServerV2,
7} from "@executor-js/api/server";
8import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";
5import {9import {
6 inMemoryMcpSessionsLayer,10 inMemoryMcpSessionsLayer,
7 makeInMemoryMcpSessionStore,11 makeInMemoryMcpSessionStore,
@@ -51,6 +55,22 @@ export const makeSelfHostMcpSessionStore = ( · 43 unmodified lines
51 { webBaseUrl },55 { webBaseUrl },
52 );56 );
53 57
58/** Build the stateless SDK v2 server seam over the same self-host stack/config. */
59export 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
54/** The `McpSessionStore` envelope seam over a freshly built in-process store. */74/** The `McpSessionStore` envelope seam over a freshly built in-process store. */
55export const selfHostMcpSessions = inMemoryMcpSessionsLayer;75export const selfHostMcpSessions = inMemoryMcpSessionsLayer;
56 76
apps/host-selfhost/src/testing/test-app.ts+2−0
@@ -28,6 +28,7 @@ import { · 27 unmodified lines
28import executorConfig from "../../executor.config";28import executorConfig from "../../executor.config";
29import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";29import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
30import {30import {
31 makeSelfHostMcpModernServerBuilder,
31 makeSelfHostMcpSessionStore,32 makeSelfHostMcpSessionStore,
32 selfHostMcpReporter,33 selfHostMcpReporter,
33 selfHostMcpSessions,34 selfHostMcpSessions,
@@ -236,6 +237,7 @@ export const makeSelfHostTestApp = async ( · 202 unmodified lines
236 mcp: {237 mcp: {
237 auth: stubMcpAuth,238 auth: stubMcpAuth,
238 sessions: selfHostMcpSessions(sessionStore),239 sessions: selfHostMcpSessions(sessionStore),
240 modern: makeSelfHostMcpModernServerBuilder(dbHandle),
239 reporter: selfHostMcpReporter,241 reporter: selfHostMcpReporter,
240 },242 },
241 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },243 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
apps/local/package.json+2−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
47 "@executor-js/vite-plugin": "workspace:*",47 "@executor-js/vite-plugin": "workspace:*",
48 "@libsql/client": "catalog:",48 "@libsql/client": "catalog:",
49 "@modelcontextprotocol/sdk": "^1.29.0",49 "@modelcontextprotocol/sdk": "^1.29.0",
50 "@modelcontextprotocol/server": "2.0.0",
50 "@tanstack/react-router": "catalog:",51 "@tanstack/react-router": "catalog:",
51 "drizzle-orm": "catalog:",52 "drizzle-orm": "catalog:",
52 "effect": "catalog:",53 "effect": "catalog:",
@@ -55,6 +56,7 @@ · 2 unmodified lines
55 "react-dom": "catalog:"56 "react-dom": "catalog:"
56 },57 },
57 "devDependencies": {58 "devDependencies": {
59 "@modelcontextprotocol/client": "2.0.0",
58 "@rhyssul/portless": "^0.13.0",60 "@rhyssul/portless": "^0.13.0",
59 "@tailwindcss/vite": "catalog:",61 "@tailwindcss/vite": "catalog:",
60 "@tanstack/router-plugin": "^1.167.12",62 "@tanstack/router-plugin": "^1.167.12",
apps/local/src/mcp-modern.test.ts+54−0
@@ -0,0 +1,54 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3import { Effect } from "effect";
4
5import type { ExecutionEngine } from "@executor-js/execution";
6
7import { createMcpRequestHandler } from "./mcp";
8
9const 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
21describe("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 @@
1import { Effect, type Cause } from "effect";1import { Effect, type Cause } from "effect";
2import {
3 createMcpHandler,
4 isLegacyRequest,
5 type McpHttpHandler,
6} from "@modelcontextprotocol/server";
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";7import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";8import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";9import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -13,6 +18,12 @@ import { · 8 unmodified lines
13 createExecutorMcpServer,18 createExecutorMcpServer,
14 type ExecutorMcpServerConfig,19 type ExecutorMcpServerConfig,
15} from "@executor-js/host-mcp/tool-server";20} from "@executor-js/host-mcp/tool-server";
21import {
22 appsEnabledForClientCapabilities,
23 buildMcpServerV2,
24 clientCapabilitiesFromRequest,
25 requestBodyFromRequest,
26} from "@executor-js/host-mcp/tool-server-v2";
16import {27import {
17 approvalUrlForRequest,28 approvalUrlForRequest,
18 decodeResumeResponse,29 decodeResumeResponse,
@@ -129,8 +140,13 @@ export const createMcpRequestHandler = ( · 110 unmodified lines
129 const resources = new Map<string, McpResource>();140 const resources = new Map<string, McpResource>();
130 const sessionEngines = new Map<string, AnyExecutionEngine>();141 const sessionEngines = new Map<string, AnyExecutionEngine>();
131 const sessionClosers = new Map<string, () => Promise<void>>();142 const sessionClosers = new Map<string, () => Promise<void>>();
143 const modernHandlers = new Map<string, McpHttpHandler>();
132 const approvals = makeInProcessBrowserApprovalStore();144 const approvals = makeInProcessBrowserApprovalStore();
133 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);145 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
146 let requestStateSigningKey: Uint8Array | undefined;
147
148 const signingKey = (): Uint8Array =>
149 (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32)));
134 150
135 const pausedDetail = (151 const pausedDetail = (
136 sessionId: string,152 sessionId: string,
@@ -164,10 +180,58 @@ export const createMcpRequestHandler = ( · 27 unmodified lines
164 await ignoreClose(close);180 await ignoreClose(close);
165 };181 };
166 182
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
167 return {227 return {
168 handleRequest: async (request) => {228 handleRequest: async (request) => {
169 const resource = resourceFromRequest(request);229 const resource = resourceFromRequest(request);
170 if (!resource) return jsonError(404, -32001, "MCP resource not found");230 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 }
171 const sessionId = request.headers.get("mcp-session-id");235 const sessionId = request.headers.get("mcp-session-id");
172 236
173 if (sessionId) {237 if (sessionId) {
@@ -283,7 +347,10 @@ export const createMcpRequestHandler = ( · 109 unmodified lines
283 347
284 close: async () => {348 close: async () => {
285 const ids = new Set([...transports.keys(), ...servers.keys()]);349 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 ]);
287 },354 },
288 };355 };
289};356};
@@ -295,6 +362,7 @@ export const createMcpRequestHandler = ( · 5 unmodified lines
295export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {362export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
296 startIntegrationsRefresh();363 startIntegrationsRefresh();
297 364
365 // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy.
298 const server = await Effect.runPromise(createExecutorMcpServer(config));366 const server = await Effect.runPromise(createExecutorMcpServer(config));
299 const transport = new StdioServerTransport();367 const transport = new StdioServerTransport();
300 368
bun.lock+3−0
@@ -257,6 +257,7 @@ · 256 unmodified lines
257 "devDependencies": {257 "devDependencies": {
258 "@effect/vitest": "catalog:",258 "@effect/vitest": "catalog:",
259 "@executor-js/vite-plugin": "workspace:*",259 "@executor-js/vite-plugin": "workspace:*",
260 "@modelcontextprotocol/client": "2.0.0",
260 "@tailwindcss/vite": "catalog:",261 "@tailwindcss/vite": "catalog:",
261 "@tanstack/router-plugin": "^1.167.12",262 "@tanstack/router-plugin": "^1.167.12",
262 "@tanstack/virtual-file-routes": "^1.162.0",263 "@tanstack/virtual-file-routes": "^1.162.0",
@@ -301,6 +302,7 @@ · 38 unmodified lines
301 "@executor-js/vite-plugin": "workspace:*",302 "@executor-js/vite-plugin": "workspace:*",
302 "@libsql/client": "catalog:",303 "@libsql/client": "catalog:",
303 "@modelcontextprotocol/sdk": "^1.29.0",304 "@modelcontextprotocol/sdk": "^1.29.0",
305 "@modelcontextprotocol/server": "2.0.0",
304 "@tanstack/react-router": "catalog:",306 "@tanstack/react-router": "catalog:",
305 "drizzle-orm": "catalog:",307 "drizzle-orm": "catalog:",
306 "effect": "catalog:",308 "effect": "catalog:",
@@ -309,6 +311,7 @@ · 2 unmodified lines
309 "react-dom": "catalog:",311 "react-dom": "catalog:",
310 },312 },
311 "devDependencies": {313 "devDependencies": {
314 "@modelcontextprotocol/client": "2.0.0",
312 "@rhyssul/portless": "^0.13.0",315 "@rhyssul/portless": "^0.13.0",
313 "@tailwindcss/vite": "catalog:",316 "@tailwindcss/vite": "catalog:",
314 "@tanstack/router-plugin": "^1.167.12",317 "@tanstack/router-plugin": "^1.167.12",
packages/core/api/src/server.ts+2−0
@@ -58,7 +58,9 @@ export { · 57 unmodified lines
58} from "./server/execution-stack";58} from "./server/execution-stack";
59export {59export {
60 makeMcpBuildServer,60 makeMcpBuildServer,
61 makeMcpBuildServerV2,
61 makeConsoleMcpErrorReporter,62 makeConsoleMcpErrorReporter,
63 type McpBuildServerV2,
62 type McpExecutionStackLayer,64 type McpExecutionStackLayer,
63} from "./server/mcp-build";65} from "./server/mcp-build";
64// Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author66// 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
15// + that stack + plugin tuple + failure strategy) (auth + per-request executor)15// + that stack + plugin tuple + failure strategy) (auth + per-request executor)
16// 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture,16// 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture,
17// router: prefixed(mountPrefix) }) wrapped by (2)17// router: prefixed(mountPrefix) }) wrapped by (2)
18// 4. the MCP serving envelope = McpServingRoutes + the 2-3 seams (auth/sessions18// 4. the MCP serving envelope = McpServingRoutes + the auth/session/modern
19// /reporter), double-provided like the host did (the seams)19// builder/reporter seams (the seams)
20// 5. the account API = makeAccountApiLayer(accountMiddleware, { router })20// 5. the account API = makeAccountApiLayer(accountMiddleware, { router })
21// 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn)21// 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn)
22// 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer22// 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer
@@ -53,6 +53,7 @@ import { · 30 unmodified lines
53 McpErrorReporterNoop,53 McpErrorReporterNoop,
54 type McpAuthProvider,54 type McpAuthProvider,
55 type McpErrorReporter,55 type McpErrorReporter,
56 type McpModernServerBuilder,
56 type McpSessionStore,57 type McpSessionStore,
57} from "@executor-js/host-mcp";58} from "@executor-js/host-mcp";
58 59
@@ -132,19 +133,32 @@ export interface EngineProviders<REngine = never> { · 73 unmodified lines
132 * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose133 * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose
133 * MCP plane is a separate credential surface leaves it `never` (cloud).134 * MCP plane is a separate credential surface leaves it `never` (cloud).
134 */135 */
135export interface McpProviders<RMcpAuth = never> {136interface McpProviderBase<RMcpAuth> {
136 /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */137 /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */
137 readonly auth: Layer.Layer<McpAuthProvider, never, RMcpAuth>;138 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>;
144 /** Forward an orchestration defect to the host's capture; default no-op. */139 /** Forward an orchestration defect to the host's capture; default no-op. */
145 readonly reporter?: Layer.Layer<McpErrorReporter>;140 readonly reporter?: Layer.Layer<McpErrorReporter>;
146}141}
147 142
143/** MCP providers either serve discovery alone or provide both protocol eras. */
144export 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
148/**162/**
149 * The provider seams common to BOTH execution models (scoped + fixed): identity,163 * The provider seams common to BOTH execution models (scoped + fixed): identity,
150 * the optional account API, the optional MCP envelope, and error capture. The164 * the optional account API, the optional MCP envelope, and error capture. The
@@ -546,12 +560,12 @@ export const make = < · 395 unmodified lines
546 : pluginApiLive;560 : pluginApiLive;
547 561
548 // ---- (4) the MCP serving envelope (optional) --------------------------562 // ---- (4) the MCP serving envelope (optional) --------------------------
549 // The two providers, by design (mirrors makeSelfHostMcp):563 // The serving providers, by design (mirrors makeSelfHostMcp):
550 // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's564 // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's
551 // build-time `McpAuthProvider` requirement (it registers a GET per565 // build-time `McpAuthProvider` requirement (it registers a GET per
552 // provider-declared discovery path).566 // provider-declared discovery path).
553 // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers'567 // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers'
554 // per-request `Requires` markers (auth + session store + reporter) so the568 // per-request `Requires` markers (auth + both eras + reporter) so the
555 // /mcp routes carry no leftover requirements when merged into the router.569 // /mcp routes carry no leftover requirements when merged into the router.
556 // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth =570 // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth =
557 // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback;571 // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback;
@@ -603,7 +617,7 @@ export const make = < · 45 unmodified lines
603};617};
604 618
605/**619/**
606 * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth620 * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth
607 * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides621 * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides
608 * the complete identity seam ONCE (memoized) and shares it across the build-time622 * the complete identity seam ONCE (memoized) and shares it across the build-time
609 * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single623 * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single
@@ -625,10 +639,15 @@ const buildMcpRoutes = <RMcpAuth>( · 15 unmodified lines
625 // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare639 // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare
626 // Agent bridge), so mount only the auth-declared discovery routes. The discovery640 // Agent bridge), so mount only the auth-declared discovery routes. The discovery
627 // handlers are captured from the auth seam at build, so no per-request seams.641 // handlers are captured from the auth seam at build, so no per-request seams.
628 if (!mcp.sessions) {642 if (mcp.sessions === undefined) {
629 return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive));643 return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive));
630 }644 }
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 );
632 return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive));651 return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive));
633};652};
634 653
packages/core/api/src/server/mcp-build.ts+53−1
@@ -1,12 +1,17 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2 2
3import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";3import {
4 McpErrorReporter,
5 type McpModernServerBuilder,
6 type Principal,
7} from "@executor-js/host-mcp";
4import {8import {
5 McpEngineBuildError,9 McpEngineBuildError,
6 type McpBuildServer,10 type McpBuildServer,
7 type McpBuildServerOptions,11 type McpBuildServerOptions,
8} from "@executor-js/host-mcp/in-memory-session-store";12} from "@executor-js/host-mcp/in-memory-session-store";
9import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";13import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
14import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
10import {15import {
11 artifactUrlFor,16 artifactUrlFor,
12 type ArtifactSmokeRenderResult,17 type ArtifactSmokeRenderResult,
@@ -90,6 +95,53 @@ export const makeMcpBuildServer = · 77 unmodified lines
90 ),95 ),
91 );96 );
92 97
98/** Build function consumed by the neutral envelope's modern-server seam. */
99export 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 */
105export 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
93/** Per-host (not per-session) MCP wiring. Kept separate from145/** Per-host (not per-session) MCP wiring. Kept separate from
94 * `McpBuildServerOptions`, which the session store fills in per request. */146 * `McpBuildServerOptions`, which the session store fills in per request. */
95export interface McpBuildHostOptions {147export interface McpBuildHostOptions {
packages/hosts/mcp/src/envelope.test.ts+143−2
@@ -10,6 +10,7 @@ · 9 unmodified lines
10// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
11 11
12import { describe, expect, it } from "@effect/vitest";12import { describe, expect, it } from "@effect/vitest";
13import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
13import { Cause, Effect, Layer, Ref } from "effect";14import { Cause, Effect, Layer, Ref } from "effect";
14import { HttpRouter, HttpServer } from "effect/unstable/http";15import { HttpRouter, HttpServer } from "effect/unstable/http";
15 16
@@ -19,13 +20,18 @@ import { · 3 unmodified lines
19 McpAuthProvider,20 McpAuthProvider,
20 McpErrorReporter,21 McpErrorReporter,
21 McpErrorReporterNoop,22 McpErrorReporterNoop,
23 McpModernServerBuilder,
22 McpServingRoutes,24 McpServingRoutes,
23 McpDiscoveryRoutes,25 McpDiscoveryRoutes,
24 McpSessionStore,26 McpSessionStore,
27 unauthorized,
25 type McpResource,28 type McpResource,
26 type McpDispatchResult,29 type McpDispatchResult,
27 type Principal,30 type Principal,
28} from "./index";31} from "./index";
32import type { ExecutionEngine } from "@executor-js/execution";
33import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
34import { buildMcpServerV2 } from "./tool-server-v2";
29 35
30const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;36const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;
31 37
@@ -39,6 +45,25 @@ const TEST_PRINCIPAL: Principal = { · 7 unmodified lines
39 roles: ["user"],45 roles: ["user"],
40};46};
41 47
48const 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
60const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({
61 build: (_principal, options) => {
62 const { resource: _resource, ...requestOptions } = options;
63 return buildMcpServerV2({ engine: testEngine, ...requestOptions });
64 },
65});
66
42/** An auth provider that authenticates everything (so dispatch is reached). */67/** An auth provider that authenticates everything (so dispatch is reached). */
43const AuthProviderLive = Layer.succeed(McpAuthProvider)({68const AuthProviderLive = Layer.succeed(McpAuthProvider)({
44 discoveryRoutes: [69 discoveryRoutes: [
@@ -68,8 +93,9 @@ const buildHandler = ( · 23 unmodified lines
68 store: Layer.Layer<McpSessionStore>,93 store: Layer.Layer<McpSessionStore>,
69 reporter: Layer.Layer<McpErrorReporter>,94 reporter: Layer.Layer<McpErrorReporter>,
70 authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive,95 authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive,
96 modernBuilder: Layer.Layer<McpModernServerBuilder> = ModernBuilderLive,
71): ((request: Request) => Promise<Response>) => {97): ((request: Request) => Promise<Response>) => {
72 const Seams = Layer.mergeAll(authProvider, store, reporter);98 const Seams = Layer.mergeAll(authProvider, store, modernBuilder, reporter);
73 const RouteLive = McpServingRoutes.pipe(99 const RouteLive = McpServingRoutes.pipe(
74 HttpRouter.provideRequest(Seams),100 HttpRouter.provideRequest(Seams),
75 Layer.provide(authProvider),101 Layer.provide(authProvider),
@@ -114,7 +140,101 @@ describe("McpServingRoutes envelope", () => { · 38 unmodified lines
114 expect(response.status).toBe(204);140 expect(response.status).toBe(204);
115 expect(response.headers.get("access-control-allow-origin")).toBe("*");141 expect(response.headers.get("access-control-allow-origin")).toBe("*");
116 expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS");142 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);
118 });238 });
119 239
120 it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => {240 it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => {
@@ -178,6 +298,27 @@ describe("McpServingRoutes envelope", () => { · 57 unmodified lines
178 });298 });
179});299});
180 300
301const 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
181it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => {322it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => {
182 const seen = await Effect.runPromise(Ref.make<McpResource | null>(null));323 const seen = await Effect.runPromise(Ref.make<McpResource | null>(null));
183 const RecordingStoreLive = Layer.succeed(McpSessionStore)({324 const RecordingStoreLive = Layer.succeed(McpSessionStore)({
packages/hosts/mcp/src/envelope.ts+143−18
@@ -1,15 +1,29 @@
1import { Effect, Match, Predicate } from "effect";1import { Effect, Match, Predicate } from "effect";
2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3import {
4 createMcpHandler,
5 isLegacyRequest,
6 type McpHttpHandler,
7 type McpRequestContext,
8} from "@modelcontextprotocol/server";
3 9
4import {10import {
5 defaultMcpResource,11 defaultMcpResource,
6 McpAuthProvider,12 McpAuthProvider,
7 McpErrorReporter,13 McpErrorReporter,
14 McpModernServerBuilder,
8 McpSessionStore,15 McpSessionStore,
16 mcpResourceKey,
9 type AuthOutcome,17 type AuthOutcome,
10 type McpDispatchResult,18 type McpDispatchResult,
11 type McpResource,19 type McpResource,
20 type Principal,
12} from "./seams";21} from "./seams";
22import {
23 appsEnabledForClientCapabilities,
24 clientCapabilitiesFromRequest,
25 requestBodyFromRequest,
26} from "./tool-server-v2";
13 27
14// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
15// Provider-neutral MCP serving envelope.29// Provider-neutral MCP serving envelope.
@@ -30,7 +44,7 @@ import { · 14 unmodified lines
30// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else44// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else
31// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz45// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz
32// semantics, and the entire session lifecycle (create + forward + ownership) —46// semantics, and the entire session lifecycle (create + forward + ownership) —
33// comes from the two seams.47// comes from the three seams.
34//48//
35// Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO49// Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO
36// platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport50// platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport
@@ -42,6 +56,14 @@ import { · 5 unmodified lines
42 56
43const MCP_PATH = "/mcp";57const MCP_PATH = "/mcp";
44const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";58const 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`.
64const MCP_CORS_ALLOWED_HEADERS =
65 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name";
66const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate";
45 67
46/** The methods the streamable-HTTP transport accepts on `/mcp`. */68/** The methods the streamable-HTTP transport accepts on `/mcp`. */
47const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);69const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -68,15 +90,19 @@ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerRespo · 20 unmodified lines
68 * preflight against the metadata docs too (RFC 9728 discovery from a 401), so90 * preflight against the metadata docs too (RFC 9728 discovery from a 401), so
69 * the envelope answers OPTIONS for those paths, not only `/mcp`.91 * the envelope answers OPTIONS for those paths, not only `/mcp`.
70 */92 */
71const corsPreflightResponse = (): Response =>93const corsPreflightResponse = (requestedHeaders?: string | null): Response =>
72 new Response(null, {94 new Response(null, {
73 status: 204,95 status: 204,
74 headers: {96 headers: {
75 "access-control-allow-origin": "*",97 "access-control-allow-origin": "*",
76 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",98 "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.
77 "access-control-allow-headers":101 "access-control-allow-headers":
78 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",102 requestedHeaders && requestedHeaders.trim() !== ""
79 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",103 ? requestedHeaders
104 : MCP_CORS_ALLOWED_HEADERS,
105 "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS,
80 },106 },
81 });107 });
82 108
@@ -212,8 +238,85 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 129 unmodified lines
212 ? jsonRpcResponse(404, -32001, "Session not found")238 ? jsonRpcResponse(404, -32001, "Session not found")
213 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");239 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
214 240
241const 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
252interface ModernRequestInputs {
253 readonly builder: McpModernServerBuilder["Service"];
254 readonly principal: Principal;
255}
256
257interface ModernMcpRouter {
258 readonly fetch: (
259 request: Request,
260 principal: Principal,
261 resource: McpResource,
262 builder: McpModernServerBuilder["Service"],
263 ) => Promise<Response>;
264}
265
266const requestStatePrincipal = (principal: Principal): string =>
267 `${principal.accountId}\u0000${principal.organizationId}`;
268
269/** Build the resource-keyed, process-lifetime modern handler cache. */
270const 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
215/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */318/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */
216const mcpDispatch = (resource: McpResource) =>319const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) =>
217 Effect.gen(function* () {320 Effect.gen(function* () {
218 const httpRequest = yield* HttpServerRequest.HttpServerRequest;321 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
219 const auth = yield* McpAuthProvider;322 const auth = yield* McpAuthProvider;
@@ -222,7 +325,9 @@ const mcpDispatch = (resource: McpResource) => · 2 unmodified lines
222 325
223 // CORS preflight: answer before auth so unauthenticated clients can probe.326 // CORS preflight: answer before auth so unauthenticated clients can probe.
224 if (request.method === "OPTIONS") {327 if (request.method === "OPTIONS") {
225 return fromWebResponse(corsPreflightResponse());328 return fromWebResponse(
329 corsPreflightResponse(request.headers.get("access-control-request-headers")),
330 );
226 }331 }
227 332
228 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other333 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
@@ -245,6 +350,14 @@ const mcpDispatch = (resource: McpResource) => · 16 unmodified lines
245 }350 }
246 const principal = outcome.principal;351 const principal = outcome.principal;
247 352
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
248 // No session id: per the streamable-HTTP transport contract, only POST opens361 // No session id: per the streamable-HTTP transport contract, only POST opens
249 // a session. A GET needs an existing id (400); a DELETE on nothing is a362 // a session. A GET needs an existing id (400); a DELETE on nothing is a
250 // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up363 // 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
280 * otherwise, since the envelope returns a `Response`) and rendered as a stable393 * otherwise, since the envelope returns a `Response`) and rendered as a stable
281 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.394 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.
282 */395 */
283const mcpRoute = (resource: McpResource) =>396const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) =>
284 mcpDispatch(resource).pipe(397 mcpDispatch(resource, modern).pipe(
285 Effect.catchCause((cause) =>398 Effect.catchCause((cause) =>
286 Effect.gen(function* () {399 Effect.gen(function* () {
287 const reporter = yield* McpErrorReporter;400 const reporter = yield* McpErrorReporter;
@@ -291,15 +404,16 @@ const mcpRoute = (resource: McpResource) => · 3 unmodified lines
291 ),404 ),
292 );405 );
293 406
294const toolkitMcpRoute = Effect.gen(function* () {407const toolkitMcpRoute = (modern: ModernMcpRouter) =>
295 const params = yield* HttpRouter.params;408 Effect.gen(function* () {
296 const slug = params.toolkitSlug;409 const params = yield* HttpRouter.params;
297 return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);410 const slug = params.toolkitSlug;
298});411 return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource, modern);
412 });
299 413
300/**414/**
301 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges415 * 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 HTTP416 * this with its other routes and provides the three seam Layers + the HTTP
303 * platform services. Provider-neutral: cloud adopts the same Layer next.417 * platform services. Provider-neutral: cloud adopts the same Layer next.
304 *418 *
305 * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so419 * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so
@@ -310,16 +424,22 @@ const toolkitMcpRoute = Effect.gen(function* () { · 4 unmodified lines
310export const McpServingRoutes = HttpRouter.use((router) =>424export const McpServingRoutes = HttpRouter.use((router) =>
311 Effect.gen(function* () {425 Effect.gen(function* () {
312 const auth = yield* McpAuthProvider;426 const auth = yield* McpAuthProvider;
427 const modern = makeModernMcpRouter();
313 for (const route of auth.discoveryRoutes) {428 for (const route of auth.discoveryRoutes) {
314 yield* router.add("GET", route.path, discoveryRoute(route.handler));429 yield* router.add("GET", route.path, discoveryRoute(route.handler));
315 yield* router.add(430 yield* router.add(
316 "OPTIONS",431 "OPTIONS",
317 route.path,432 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 }),
319 );439 );
320 }440 }
321 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));441 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource, modern));
322 yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);442 yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute(modern));
323 }),443 }),
324);444);
325 445
@@ -341,7 +461,12 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) => · 15 unmodified lines
341 yield* router.add(461 yield* router.add(
342 "OPTIONS",462 "OPTIONS",
343 route.path,463 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 }),
345 );470 );
346 }471 }
347 }),472 }),
packages/hosts/mcp/src/index.ts+2−0
@@ -16,6 +16,7 @@ export { · 15 unmodified lines
16 Principal,16 Principal,
17 McpAuthProvider,17 McpAuthProvider,
18 McpSessionStore,18 McpSessionStore,
19 McpModernServerBuilder,
19 McpErrorReporter,20 McpErrorReporter,
20 McpErrorReporterNoop,21 McpErrorReporterNoop,
21 defaultMcpResource,22 defaultMcpResource,
@@ -33,6 +34,7 @@ export { · 11 unmodified lines
33 type McpDiscoveryRoute,34 type McpDiscoveryRoute,
34 type McpDispatchInput,35 type McpDispatchInput,
35 type McpDispatchResult,36 type McpDispatchResult,
37 type McpModernServerBuildOptions,
36 type McpResource,38 type McpResource,
37} from "./seams";39} from "./seams";
38 40
packages/hosts/mcp/src/seams.ts+41−7
@@ -1,10 +1,11 @@
1import { Context, Effect, Layer, Schema } from "effect";1import { Context, Effect, Layer, Schema } from "effect";
2import type { Cause } from "effect";2import type { Cause } from "effect";
3import type { McpServer } from "@modelcontextprotocol/server";
3 4
4// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
5// Provider-neutral MCP serving seams.6// Provider-neutral MCP serving seams.
6//7//
7// The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO8// The shared MCP serving envelope (see `./envelope`) depends ONLY on these THREE
8// seams. Each product (self-host, cloud, local) provides its own Layer9// seams. Each product (self-host, cloud, local) provides its own Layer
9// satisfying the same tags; the envelope never changes. The seams are kept10// satisfying the same tags; the envelope never changes. The seams are kept
10// deliberately small — anything provider-specific (Durable-Object trace11// deliberately small — anything provider-specific (Durable-Object trace
@@ -12,17 +13,18 @@ import type { Cause } from "effect"; · 1 unmodified line
12// per-org engine construction) is configured *inside* a provider's adapter and13// per-org engine construction) is configured *inside* a provider's adapter and
13// never baked into the envelope.14// never baked into the envelope.
14//15//
15// Two seams, deliberately:16// Three seams, deliberately:
16// 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize17// 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize
17// (it may read the `mcp-session-id` header to do session-aware org-authz).18// (it may read the `mcp-session-id` header to do session-aware org-authz).
18// 2. McpSessionStore — owns the serving session lifecycle: create + forward +19// 2. McpSessionStore — owns the serving session lifecycle: create + forward +
19// ownership, end to end, via a single `dispatch`. The store builds/forwards20// ownership, end to end, via a single `dispatch`. The store builds/forwards
20// the transport and returns the transport `Response`.21// 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.
21//24//
22// There is deliberately NO envelope-level engine seam. Self-host's in-process25// There is deliberately NO envelope-level engine seam. Both server builders
23// store builds its engine via an INTERNAL dependency (its Layer provides it);26// remain host adapters; the envelope only chooses the protocol era and supplies
24// cloud's Durable-Object store builds its engine inside the DO. The engine is a27// request/resource/auth context.
25// store implementation detail, not an envelope seam.
26// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
27 29
28// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
@@ -271,7 +273,39 @@ export class McpSessionStore extends Context.Service< · 242 unmodified lines
271>()("@executor-js/host-mcp/McpSessionStore") {}273>()("@executor-js/host-mcp/McpSessionStore") {}
272 274
273// ===========================================================================275// ===========================================================================
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. */
280export 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 */
297export 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.
275//309//
276// The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and310// The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and
277// renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the311// 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 @@
1/**1/**
2 * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era.2 * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era.
3 *3 *
4 * A later host PR will call {@link buildMcpServerV2} from a4 * Neutral hosts call {@link buildMcpServerV2} from a `createMcpHandler`
5 * `createMcpHandler` `McpServerFactory`, once per request. The factory's5 * `McpServerFactory`, once per request. The factory's
6 * `McpRequestContext.requestInfo` exposes the original HTTP request; the host6 * `McpRequestContext.requestInfo` exposes the original HTTP request, which
7 * reads its modern `_meta` envelope, extracts `CLIENT_CAPABILITIES_META_KEY`,7 * {@link clientCapabilitiesFromRequest} parses for the request-scoped
8 * and passes the request-scoped {@link appsEnabledForClientCapabilities}8 * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a
9 * decision here. This package deliberately does not replace existing v1 host9 * separate host path.
10 * routing.
11 */10 */
12import { Effect, Match, Option, Schema } from "effect";11import { Effect, Match, Option, Schema } from "effect";
13import * as Cause from "effect/Cause";12import * as Cause from "effect/Cause";
14import {13import {
15 acceptedContent,14 acceptedContent,
15 CLIENT_CAPABILITIES_META_KEY,
16 createRequestStateCodec,16 createRequestStateCodec,
17 fromJsonSchema,17 fromJsonSchema,
18 inputRequired,18 inputRequired,
@@ -28,6 +28,7 @@ import type { ElicitationRequest } from "@executor-js/sdk"; · 9 unmodified lines
28 28
29import {29import {
30 getUiCapability,30 getUiCapability,
31 EXTENSION_ID,
31 registerAppResource,32 registerAppResource,
32 registerAppTool,33 registerAppTool,
33 RESOURCE_MIME_TYPE,34 RESOURCE_MIME_TYPE,
@@ -79,15 +80,56 @@ export const appsEnabledForClientCapabilities = ( · 45 unmodified lines
79 clientCapabilities: McpAppsClientCapabilities | null | undefined,80 clientCapabilities: McpAppsClientCapabilities | null | undefined,
80): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));81): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
81 82
83const isRecord = (value: unknown): value is Record<string, unknown> =>
84 typeof value === "object" && value !== null && !Array.isArray(value);
85
86const 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. */
107export 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 */
122export const clientCapabilitiesFromRequest = (
123 request: Request,
124): Effect.Effect<McpAppsClientCapabilities | null> =>
125 requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromUnknown));
126
82const requestJoinKeys = (context: ServerContext): V2RequestContext => ({127const requestJoinKeys = (context: ServerContext): V2RequestContext => ({
83 requestId: context.mcpReq.id,128 requestId: context.mcpReq.id,
84 ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),129 ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
85 serverContext: context,130 serverContext: context,
86});131});
87 132
88const isRecord = (value: unknown): value is Record<string, unknown> =>
89 typeof value === "object" && value !== null && !Array.isArray(value);
90
91const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {133const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {
92 const ui = metadata.ui;134 const ui = metadata.ui;
93 if (!isRecord(ui)) return undefined;135 if (!isRecord(ui)) return undefined;