Unified · Split
| @@ -53,6 +53,7 @@ · 52 unmodified lines | ||
| 53 | 53 | "devDependencies": { |
| 54 | 54 | "@effect/vitest": "catalog:", |
| 55 | 55 | "@executor-js/vite-plugin": "workspace:*", |
| 56 | + "@modelcontextprotocol/client": "2.0.0", | |
| 56 | 57 | "@tailwindcss/vite": "catalog:", |
| 57 | 58 | "@tanstack/router-plugin": "^1.167.12", |
| 58 | 59 | "@tanstack/virtual-file-routes": "^1.162.0", |
| @@ -110,7 +110,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 109 unmodified lines | ||
| 110 | 110 | // plane's decorator is wired in mcp/session-store.ts's stack layer). |
| 111 | 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 | 119 | plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, |
| 115 | 120 | errorCapture: ErrorCaptureLive, |
| 116 | 121 | }, |
| @@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines | ||
| 4 | 4 | import type { |
| 5 | 5 | McpAuthProvider, |
| 6 | 6 | McpErrorReporter, |
| 7 | + McpModernServerBuilder, | |
| 7 | 8 | McpSessionStore, |
| 8 | 9 | Principal, |
| 9 | 10 | } from "@executor-js/host-mcp"; |
| @@ -13,6 +14,7 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; · 3 unmodified lines | ||
| 13 | 14 | import { selfHostMcpAuth } from "./auth"; |
| 14 | 15 | import { |
| 15 | 16 | makeSelfHostMcpSessionStore, |
| 17 | + makeSelfHostMcpModernServerBuilder, | |
| 16 | 18 | selfHostMcpReporter, |
| 17 | 19 | selfHostMcpSessions, |
| 18 | 20 | } from "./session-store"; |
| @@ -20,6 +22,7 @@ import { · 1 unmodified line | ||
| 20 | 22 | export { selfHostMcpAuth } from "./auth"; |
| 21 | 23 | export { |
| 22 | 24 | makeSelfHostMcpSessionStore, |
| 25 | + makeSelfHostMcpModernServerBuilder, | |
| 23 | 26 | selfHostMcpReporter, |
| 24 | 27 | selfHostMcpSessions, |
| 25 | 28 | McpEngineBuildError, |
| @@ -34,13 +37,15 @@ export { · 8 unmodified lines | ||
| 34 | 37 | // own auth + session handling and is mounted OUTSIDE the API's execution |
| 35 | 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 | 41 | // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still |
| 39 | 42 | // requires `IdentityProvider`, which `make` provides from |
| 40 | 43 | // the resolved identity seam. |
| 41 | 44 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns |
| 42 | 45 | // dispatch (create + forward + ownership) and builds its |
| 43 | 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 | 49 | // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the |
| 45 | 50 | // host's console capture. |
| 46 | 51 | // |
| @@ -53,6 +58,8 @@ export interface SelfHostMcpSeams { · 6 unmodified lines | ||
| 53 | 58 | readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>; |
| 54 | 59 | /** The in-process session store seam (dispatch + lifetime). */ |
| 55 | 60 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 61 | + /** Stateless SDK v2 server construction for modern requests. */ | |
| 62 | + readonly modern: Layer.Layer<McpModernServerBuilder>; | |
| 56 | 63 | /** Route 500 defects through the host's console `ErrorCapture`. */ |
| 57 | 64 | readonly reporter: Layer.Layer<McpErrorReporter>; |
| 58 | 65 | /** |
| @@ -126,7 +133,7 @@ const makeApprovalHandler = · 67 unmodified lines | ||
| 126 | 133 | * Build the self-host MCP serving seams over the long-lived DB handle. The auth |
| 127 | 134 | * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth |
| 128 | 135 | * 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 | |
| 130 | 137 | * the app wires into shutdown. |
| 131 | 138 | */ |
| 132 | 139 | export const makeSelfHostMcpSeams = ( |
| @@ -141,6 +148,7 @@ export const makeSelfHostMcpSeams = ( · 8 unmodified lines | ||
| 141 | 148 | return { |
| 142 | 149 | auth, |
| 143 | 150 | sessions: selfHostMcpSessions(sessionStore), |
| 151 | + modern: makeSelfHostMcpModernServerBuilder(dbHandle), | |
| 144 | 152 | reporter: selfHostMcpReporter, |
| 145 | 153 | approvalHandler: makeApprovalHandler(sessionStore, betterAuth), |
| 146 | 154 | close: sessionStore.close, |
| @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; · 2 unmodified lines | ||
| 3 | 3 | import { join } from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { afterAll, expect, test } from "@effect/vitest"; |
| 6 | +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | |
| 6 | 7 | |
| 7 | 8 | import { 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 | 88 | expect(JSON.stringify(await call.json())).toContain("42"); |
| 88 | 89 | }); |
| 89 | 90 | |
| 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 | + | |
| 90 | 125 | test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { |
| 91 | 126 | const alice = await signUp("alice2@mcp.test"); |
| 92 | 127 | const bob = await signUp("bob2@mcp.test"); |
| @@ -1,7 +1,11 @@ | ||
| 1 | 1 | import { Layer } from "effect"; |
| 2 | 2 | |
| 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"; | |
| 5 | 9 | import { |
| 6 | 10 | inMemoryMcpSessionsLayer, |
| 7 | 11 | makeInMemoryMcpSessionStore, |
| @@ -51,6 +55,22 @@ export const makeSelfHostMcpSessionStore = ( · 43 unmodified lines | ||
| 51 | 55 | { webBaseUrl }, |
| 52 | 56 | ); |
| 53 | 57 | |
| 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 | + | |
| 54 | 74 | /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ |
| 55 | 75 | export const selfHostMcpSessions = inMemoryMcpSessionsLayer; |
| 56 | 76 | |
| @@ -28,6 +28,7 @@ import { · 27 unmodified lines | ||
| 28 | 28 | import executorConfig from "../../executor.config"; |
| 29 | 29 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; |
| 30 | 30 | import { |
| 31 | + makeSelfHostMcpModernServerBuilder, | |
| 31 | 32 | makeSelfHostMcpSessionStore, |
| 32 | 33 | selfHostMcpReporter, |
| 33 | 34 | selfHostMcpSessions, |
| @@ -236,6 +237,7 @@ export const makeSelfHostTestApp = async ( · 202 unmodified lines | ||
| 236 | 237 | mcp: { |
| 237 | 238 | auth: stubMcpAuth, |
| 238 | 239 | sessions: selfHostMcpSessions(sessionStore), |
| 240 | + modern: makeSelfHostMcpModernServerBuilder(dbHandle), | |
| 239 | 241 | reporter: selfHostMcpReporter, |
| 240 | 242 | }, |
| 241 | 243 | plugins: { provider: pluginsProvider, config: SelfHostHostConfig }, |
| @@ -47,6 +47,7 @@ · 46 unmodified lines | ||
| 47 | 47 | "@executor-js/vite-plugin": "workspace:*", |
| 48 | 48 | "@libsql/client": "catalog:", |
| 49 | 49 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 50 | + "@modelcontextprotocol/server": "2.0.0", | |
| 50 | 51 | "@tanstack/react-router": "catalog:", |
| 51 | 52 | "drizzle-orm": "catalog:", |
| 52 | 53 | "effect": "catalog:", |
| @@ -55,6 +56,7 @@ · 2 unmodified lines | ||
| 55 | 56 | "react-dom": "catalog:" |
| 56 | 57 | }, |
| 57 | 58 | "devDependencies": { |
| 59 | + "@modelcontextprotocol/client": "2.0.0", | |
| 58 | 60 | "@rhyssul/portless": "^0.13.0", |
| 59 | 61 | "@tailwindcss/vite": "catalog:", |
| 60 | 62 | "@tanstack/router-plugin": "^1.167.12", |
| @@ -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 | +}); | |
| @@ -1,4 +1,9 @@ | ||
| 1 | 1 | import { Effect, type Cause } from "effect"; |
| 2 | +import { | |
| 3 | + createMcpHandler, | |
| 4 | + isLegacyRequest, | |
| 5 | + type McpHttpHandler, | |
| 6 | +} from "@modelcontextprotocol/server"; | |
| 2 | 7 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | 8 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 4 | 9 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| @@ -13,6 +18,12 @@ import { · 8 unmodified lines | ||
| 13 | 18 | createExecutorMcpServer, |
| 14 | 19 | type ExecutorMcpServerConfig, |
| 15 | 20 | } 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"; | |
| 16 | 27 | import { |
| 17 | 28 | approvalUrlForRequest, |
| 18 | 29 | decodeResumeResponse, |
| @@ -129,8 +140,13 @@ export const createMcpRequestHandler = ( · 110 unmodified lines | ||
| 129 | 140 | const resources = new Map<string, McpResource>(); |
| 130 | 141 | const sessionEngines = new Map<string, AnyExecutionEngine>(); |
| 131 | 142 | const sessionClosers = new Map<string, () => Promise<void>>(); |
| 143 | + const modernHandlers = new Map<string, McpHttpHandler>(); | |
| 132 | 144 | const approvals = makeInProcessBrowserApprovalStore(); |
| 133 | 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 | 151 | const pausedDetail = ( |
| 136 | 152 | sessionId: string, |
| @@ -164,10 +180,58 @@ export const createMcpRequestHandler = ( · 27 unmodified lines | ||
| 164 | 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 | 227 | return { |
| 168 | 228 | handleRequest: async (request) => { |
| 169 | 229 | const resource = resourceFromRequest(request); |
| 170 | 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 | 235 | const sessionId = request.headers.get("mcp-session-id"); |
| 172 | 236 | |
| 173 | 237 | if (sessionId) { |
| @@ -283,7 +347,10 @@ export const createMcpRequestHandler = ( · 109 unmodified lines | ||
| 283 | 347 | |
| 284 | 348 | close: async () => { |
| 285 | 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 | ||
| 295 | 362 | export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => { |
| 296 | 363 | startIntegrationsRefresh(); |
| 297 | 364 | |
| 365 | + // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy. | |
| 298 | 366 | const server = await Effect.runPromise(createExecutorMcpServer(config)); |
| 299 | 367 | const transport = new StdioServerTransport(); |
| 300 | 368 | |
| @@ -257,6 +257,7 @@ · 256 unmodified lines | ||
| 257 | 257 | "devDependencies": { |
| 258 | 258 | "@effect/vitest": "catalog:", |
| 259 | 259 | "@executor-js/vite-plugin": "workspace:*", |
| 260 | + "@modelcontextprotocol/client": "2.0.0", | |
| 260 | 261 | "@tailwindcss/vite": "catalog:", |
| 261 | 262 | "@tanstack/router-plugin": "^1.167.12", |
| 262 | 263 | "@tanstack/virtual-file-routes": "^1.162.0", |
| @@ -301,6 +302,7 @@ · 38 unmodified lines | ||
| 301 | 302 | "@executor-js/vite-plugin": "workspace:*", |
| 302 | 303 | "@libsql/client": "catalog:", |
| 303 | 304 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 305 | + "@modelcontextprotocol/server": "2.0.0", | |
| 304 | 306 | "@tanstack/react-router": "catalog:", |
| 305 | 307 | "drizzle-orm": "catalog:", |
| 306 | 308 | "effect": "catalog:", |
| @@ -309,6 +311,7 @@ · 2 unmodified lines | ||
| 309 | 311 | "react-dom": "catalog:", |
| 310 | 312 | }, |
| 311 | 313 | "devDependencies": { |
| 314 | + "@modelcontextprotocol/client": "2.0.0", | |
| 312 | 315 | "@rhyssul/portless": "^0.13.0", |
| 313 | 316 | "@tailwindcss/vite": "catalog:", |
| 314 | 317 | "@tanstack/router-plugin": "^1.167.12", |
| @@ -58,7 +58,9 @@ export { · 57 unmodified lines | ||
| 58 | 58 | } from "./server/execution-stack"; |
| 59 | 59 | export { |
| 60 | 60 | makeMcpBuildServer, |
| 61 | + makeMcpBuildServerV2, | |
| 61 | 62 | makeConsoleMcpErrorReporter, |
| 63 | + type McpBuildServerV2, | |
| 62 | 64 | type McpExecutionStackLayer, |
| 63 | 65 | } from "./server/mcp-build"; |
| 64 | 66 | // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author |
| @@ -15,8 +15,8 @@ · 14 unmodified lines | ||
| 15 | 15 | // + that stack + plugin tuple + failure strategy) (auth + per-request executor) |
| 16 | 16 | // 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture, |
| 17 | 17 | // 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) | |
| 20 | 20 | // 5. the account API = makeAccountApiLayer(accountMiddleware, { router }) |
| 21 | 21 | // 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn) |
| 22 | 22 | // 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer |
| @@ -53,6 +53,7 @@ import { · 30 unmodified lines | ||
| 53 | 53 | McpErrorReporterNoop, |
| 54 | 54 | type McpAuthProvider, |
| 55 | 55 | type McpErrorReporter, |
| 56 | + type McpModernServerBuilder, | |
| 56 | 57 | type McpSessionStore, |
| 57 | 58 | } from "@executor-js/host-mcp"; |
| 58 | 59 | |
| @@ -132,19 +133,32 @@ export interface EngineProviders<REngine = never> { · 73 unmodified lines | ||
| 132 | 133 | * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose |
| 133 | 134 | * MCP plane is a separate credential surface leaves it `never` (cloud). |
| 134 | 135 | */ |
| 135 | −export interface McpProviders<RMcpAuth = never> { | |
| 136 | +interface McpProviderBase<RMcpAuth> { | |
| 136 | 137 | /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */ |
| 137 | 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 | 139 | /** Forward an orchestration defect to the host's capture; default no-op. */ |
| 145 | 140 | readonly reporter?: Layer.Layer<McpErrorReporter>; |
| 146 | 141 | } |
| 147 | 142 | |
| 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 | + | |
| 148 | 162 | /** |
| 149 | 163 | * The provider seams common to BOTH execution models (scoped + fixed): identity, |
| 150 | 164 | * the optional account API, the optional MCP envelope, and error capture. The |
| @@ -546,12 +560,12 @@ export const make = < · 395 unmodified lines | ||
| 546 | 560 | : pluginApiLive; |
| 547 | 561 | |
| 548 | 562 | // ---- (4) the MCP serving envelope (optional) -------------------------- |
| 549 | − // The two providers, by design (mirrors makeSelfHostMcp): | |
| 563 | + // The serving providers, by design (mirrors makeSelfHostMcp): | |
| 550 | 564 | // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's |
| 551 | 565 | // build-time `McpAuthProvider` requirement (it registers a GET per |
| 552 | 566 | // provider-declared discovery path). |
| 553 | 567 | // - `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 | |
| 555 | 569 | // /mcp routes carry no leftover requirements when merged into the router. |
| 556 | 570 | // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth = |
| 557 | 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 auth | |
| 620 | + * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth | |
| 607 | 621 | * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides |
| 608 | 622 | * the complete identity seam ONCE (memoized) and shares it across the build-time |
| 609 | 623 | * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single |
| @@ -625,10 +639,15 @@ const buildMcpRoutes = <RMcpAuth>( · 15 unmodified lines | ||
| 625 | 639 | // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare |
| 626 | 640 | // Agent bridge), so mount only the auth-declared discovery routes. The discovery |
| 627 | 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 | 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 | 651 | return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive)); |
| 633 | 652 | }; |
| 634 | 653 | |
| @@ -1,12 +1,17 @@ | ||
| 1 | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | |
| 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"; | |
| 4 | 8 | import { |
| 5 | 9 | McpEngineBuildError, |
| 6 | 10 | type McpBuildServer, |
| 7 | 11 | type McpBuildServerOptions, |
| 8 | 12 | } from "@executor-js/host-mcp/in-memory-session-store"; |
| 9 | 13 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 14 | +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; | |
| 10 | 15 | import { |
| 11 | 16 | artifactUrlFor, |
| 12 | 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. */ | |
| 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 | + | |
| 93 | 145 | /** Per-host (not per-session) MCP wiring. Kept separate from |
| 94 | 146 | * `McpBuildServerOptions`, which the session store fills in per request. */ |
| 95 | 147 | export interface McpBuildHostOptions { |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | |
| 12 | 12 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | |
| 13 | 14 | import { Cause, Effect, Layer, Ref } from "effect"; |
| 14 | 15 | import { HttpRouter, HttpServer } from "effect/unstable/http"; |
| 15 | 16 | |
| @@ -19,13 +20,18 @@ import { · 3 unmodified lines | ||
| 19 | 20 | McpAuthProvider, |
| 20 | 21 | McpErrorReporter, |
| 21 | 22 | McpErrorReporterNoop, |
| 23 | + McpModernServerBuilder, | |
| 22 | 24 | McpServingRoutes, |
| 23 | 25 | McpDiscoveryRoutes, |
| 24 | 26 | McpSessionStore, |
| 27 | + unauthorized, | |
| 25 | 28 | type McpResource, |
| 26 | 29 | type McpDispatchResult, |
| 27 | 30 | type Principal, |
| 28 | 31 | } 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"; | |
| 29 | 35 | |
| 30 | 36 | const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; |
| 31 | 37 | |
| @@ -39,6 +45,25 @@ const TEST_PRINCIPAL: Principal = { · 7 unmodified lines | ||
| 39 | 45 | roles: ["user"], |
| 40 | 46 | }; |
| 41 | 47 | |
| 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 | + | |
| 42 | 67 | /** An auth provider that authenticates everything (so dispatch is reached). */ |
| 43 | 68 | const AuthProviderLive = Layer.succeed(McpAuthProvider)({ |
| 44 | 69 | discoveryRoutes: [ |
| @@ -68,8 +93,9 @@ const buildHandler = ( · 23 unmodified lines | ||
| 68 | 93 | store: Layer.Layer<McpSessionStore>, |
| 69 | 94 | reporter: Layer.Layer<McpErrorReporter>, |
| 70 | 95 | authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive, |
| 96 | + modernBuilder: Layer.Layer<McpModernServerBuilder> = ModernBuilderLive, | |
| 71 | 97 | ): ((request: Request) => Promise<Response>) => { |
| 72 | − const Seams = Layer.mergeAll(authProvider, store, reporter); | |
| 98 | + const Seams = Layer.mergeAll(authProvider, store, modernBuilder, reporter); | |
| 73 | 99 | const RouteLive = McpServingRoutes.pipe( |
| 74 | 100 | HttpRouter.provideRequest(Seams), |
| 75 | 101 | Layer.provide(authProvider), |
| @@ -114,7 +140,101 @@ describe("McpServingRoutes envelope", () => { · 38 unmodified lines | ||
| 114 | 140 | expect(response.status).toBe(204); |
| 115 | 141 | expect(response.headers.get("access-control-allow-origin")).toBe("*"); |
| 116 | 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 | 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 | |
| 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 | + | |
| 181 | 322 | it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { |
| 182 | 323 | const seen = await Effect.runPromise(Ref.make<McpResource | null>(null)); |
| 183 | 324 | const RecordingStoreLive = Layer.succeed(McpSessionStore)({ |
| @@ -1,15 +1,29 @@ | ||
| 1 | 1 | import { Effect, Match, Predicate } from "effect"; |
| 2 | 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | +import { | |
| 4 | + createMcpHandler, | |
| 5 | + isLegacyRequest, | |
| 6 | + type McpHttpHandler, | |
| 7 | + type McpRequestContext, | |
| 8 | +} from "@modelcontextprotocol/server"; | |
| 3 | 9 | |
| 4 | 10 | import { |
| 5 | 11 | defaultMcpResource, |
| 6 | 12 | McpAuthProvider, |
| 7 | 13 | McpErrorReporter, |
| 14 | + McpModernServerBuilder, | |
| 8 | 15 | McpSessionStore, |
| 16 | + mcpResourceKey, | |
| 9 | 17 | type AuthOutcome, |
| 10 | 18 | type McpDispatchResult, |
| 11 | 19 | type McpResource, |
| 20 | + type Principal, | |
| 12 | 21 | } from "./seams"; |
| 22 | +import { | |
| 23 | + appsEnabledForClientCapabilities, | |
| 24 | + clientCapabilitiesFromRequest, | |
| 25 | + requestBodyFromRequest, | |
| 26 | +} from "./tool-server-v2"; | |
| 13 | 27 | |
| 14 | 28 | // --------------------------------------------------------------------------- |
| 15 | 29 | // Provider-neutral MCP serving envelope. |
| @@ -30,7 +44,7 @@ import { · 14 unmodified lines | ||
| 30 | 44 | // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else |
| 31 | 45 | // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz |
| 32 | 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 | 49 | // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO |
| 36 | 50 | // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport |
| @@ -42,6 +56,14 @@ import { · 5 unmodified lines | ||
| 42 | 56 | |
| 43 | 57 | const MCP_PATH = "/mcp"; |
| 44 | 58 | 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"; | |
| 45 | 67 | |
| 46 | 68 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 47 | 69 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| @@ -68,15 +90,19 @@ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerRespo · 20 unmodified lines | ||
| 68 | 90 | * preflight against the metadata docs too (RFC 9728 discovery from a 401), so |
| 69 | 91 | * the envelope answers OPTIONS for those paths, not only `/mcp`. |
| 70 | 92 | */ |
| 71 | −const corsPreflightResponse = (): Response => | |
| 93 | +const corsPreflightResponse = (requestedHeaders?: string | null): Response => | |
| 72 | 94 | new Response(null, { |
| 73 | 95 | status: 204, |
| 74 | 96 | headers: { |
| 75 | 97 | "access-control-allow-origin": "*", |
| 76 | 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 | 101 | "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, | |
| 80 | 106 | }, |
| 81 | 107 | }); |
| 82 | 108 | |
| @@ -212,8 +238,85 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 129 unmodified lines | ||
| 212 | 238 | ? jsonRpcResponse(404, -32001, "Session not found") |
| 213 | 239 | : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); |
| 214 | 240 | |
| 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 | + | |
| 215 | 318 | /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ |
| 216 | −const mcpDispatch = (resource: McpResource) => | |
| 319 | +const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => | |
| 217 | 320 | Effect.gen(function* () { |
| 218 | 321 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; |
| 219 | 322 | const auth = yield* McpAuthProvider; |
| @@ -222,7 +325,9 @@ const mcpDispatch = (resource: McpResource) => · 2 unmodified lines | ||
| 222 | 325 | |
| 223 | 326 | // CORS preflight: answer before auth so unauthenticated clients can probe. |
| 224 | 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 | 333 | // 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 | 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 | 361 | // No session id: per the streamable-HTTP transport contract, only POST opens |
| 249 | 362 | // a session. A GET needs an existing id (400); a DELETE on nothing is a |
| 250 | 363 | // 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 | 393 | * otherwise, since the envelope returns a `Response`) and rendered as a stable |
| 281 | 394 | * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. |
| 282 | 395 | */ |
| 283 | −const mcpRoute = (resource: McpResource) => | |
| 284 | − mcpDispatch(resource).pipe( | |
| 396 | +const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) => | |
| 397 | + mcpDispatch(resource, modern).pipe( | |
| 285 | 398 | Effect.catchCause((cause) => |
| 286 | 399 | Effect.gen(function* () { |
| 287 | 400 | const reporter = yield* McpErrorReporter; |
| @@ -291,15 +404,16 @@ const mcpRoute = (resource: McpResource) => · 3 unmodified lines | ||
| 291 | 404 | ), |
| 292 | 405 | ); |
| 293 | 406 | |
| 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 | + }); | |
| 299 | 413 | |
| 300 | 414 | /** |
| 301 | 415 | * 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 | |
| 303 | 417 | * platform services. Provider-neutral: cloud adopts the same Layer next. |
| 304 | 418 | * |
| 305 | 419 | * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so |
| @@ -310,16 +424,22 @@ const toolkitMcpRoute = Effect.gen(function* () { · 4 unmodified lines | ||
| 310 | 424 | export const McpServingRoutes = HttpRouter.use((router) => |
| 311 | 425 | Effect.gen(function* () { |
| 312 | 426 | const auth = yield* McpAuthProvider; |
| 427 | + const modern = makeModernMcpRouter(); | |
| 313 | 428 | for (const route of auth.discoveryRoutes) { |
| 314 | 429 | yield* router.add("GET", route.path, discoveryRoute(route.handler)); |
| 315 | 430 | yield* router.add( |
| 316 | 431 | "OPTIONS", |
| 317 | 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)); | |
| 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)); | |
| 323 | 443 | }), |
| 324 | 444 | ); |
| 325 | 445 | |
| @@ -341,7 +461,12 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) => · 15 unmodified lines | ||
| 341 | 461 | yield* router.add( |
| 342 | 462 | "OPTIONS", |
| 343 | 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 | }), |
| @@ -16,6 +16,7 @@ export { · 15 unmodified lines | ||
| 16 | 16 | Principal, |
| 17 | 17 | McpAuthProvider, |
| 18 | 18 | McpSessionStore, |
| 19 | + McpModernServerBuilder, | |
| 19 | 20 | McpErrorReporter, |
| 20 | 21 | McpErrorReporterNoop, |
| 21 | 22 | defaultMcpResource, |
| @@ -33,6 +34,7 @@ export { · 11 unmodified lines | ||
| 33 | 34 | type McpDiscoveryRoute, |
| 34 | 35 | type McpDispatchInput, |
| 35 | 36 | type McpDispatchResult, |
| 37 | + type McpModernServerBuildOptions, | |
| 36 | 38 | type McpResource, |
| 37 | 39 | } from "./seams"; |
| 38 | 40 | |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { Context, Effect, Layer, Schema } from "effect"; |
| 2 | 2 | import type { Cause } from "effect"; |
| 3 | +import type { McpServer } from "@modelcontextprotocol/server"; | |
| 3 | 4 | |
| 4 | 5 | // --------------------------------------------------------------------------- |
| 5 | 6 | // Provider-neutral MCP serving seams. |
| 6 | 7 | // |
| 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 | |
| 8 | 9 | // seams. Each product (self-host, cloud, local) provides its own Layer |
| 9 | 10 | // satisfying the same tags; the envelope never changes. The seams are kept |
| 10 | 11 | // deliberately small — anything provider-specific (Durable-Object trace |
| @@ -12,17 +13,18 @@ import type { Cause } from "effect"; · 1 unmodified line | ||
| 12 | 13 | // per-org engine construction) is configured *inside* a provider's adapter and |
| 13 | 14 | // never baked into the envelope. |
| 14 | 15 | // |
| 15 | −// Two seams, deliberately: | |
| 16 | +// Three seams, deliberately: | |
| 16 | 17 | // 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize |
| 17 | 18 | // (it may read the `mcp-session-id` header to do session-aware org-authz). |
| 18 | 19 | // 2. McpSessionStore — owns the serving session lifecycle: create + forward + |
| 19 | 20 | // ownership, end to end, via a single `dispatch`. The store builds/forwards |
| 20 | 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-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. | |
| 26 | 28 | // --------------------------------------------------------------------------- |
| 27 | 29 | |
| 28 | 30 | // --------------------------------------------------------------------------- |
| @@ -271,7 +273,39 @@ export class McpSessionStore extends Context.Service< · 242 unmodified lines | ||
| 271 | 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. */ | |
| 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. | |
| 275 | 309 | // |
| 276 | 310 | // The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and |
| 277 | 311 | // renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the |
| @@ -1,18 +1,18 @@ | ||
| 1 | 1 | /** |
| 2 | 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 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. | |
| 11 | 10 | */ |
| 12 | 11 | import { Effect, Match, Option, Schema } from "effect"; |
| 13 | 12 | import * as Cause from "effect/Cause"; |
| 14 | 13 | import { |
| 15 | 14 | acceptedContent, |
| 15 | + CLIENT_CAPABILITIES_META_KEY, | |
| 16 | 16 | createRequestStateCodec, |
| 17 | 17 | fromJsonSchema, |
| 18 | 18 | inputRequired, |
| @@ -28,6 +28,7 @@ import type { ElicitationRequest } from "@executor-js/sdk"; · 9 unmodified lines | ||
| 28 | 28 | |
| 29 | 29 | import { |
| 30 | 30 | getUiCapability, |
| 31 | + EXTENSION_ID, | |
| 31 | 32 | registerAppResource, |
| 32 | 33 | registerAppTool, |
| 33 | 34 | RESOURCE_MIME_TYPE, |
| @@ -79,15 +80,56 @@ export const appsEnabledForClientCapabilities = ( · 45 unmodified lines | ||
| 79 | 80 | clientCapabilities: McpAppsClientCapabilities | null | undefined, |
| 80 | 81 | ): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); |
| 81 | 82 | |
| 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 | + | |
| 82 | 127 | const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ |
| 83 | 128 | requestId: context.mcpReq.id, |
| 84 | 129 | ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), |
| 85 | 130 | serverContext: context, |
| 86 | 131 | }); |
| 87 | 132 | |
| 88 | −const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 89 | − typeof value === "object" && value !== null && !Array.isArray(value); | |
| 90 | − | |
| 91 | 133 | const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => { |
| 92 | 134 | const ui = metadata.ui; |
| 93 | 135 | if (!isRecord(ui)) return undefined; |