Unified · Split
| @@ -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", |
| @@ -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 | }, |
| @@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines | |||
| 4 | import type { | 4 | import 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 | |||
| 13 | import { selfHostMcpAuth } from "./auth"; | 14 | import { selfHostMcpAuth } from "./auth"; |
| 14 | import { | 15 | import { |
| 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 | |||
| 20 | export { selfHostMcpAuth } from "./auth"; | 22 | export { selfHostMcpAuth } from "./auth"; |
| 21 | export { | 23 | export { |
| 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 execution | 37 | // 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 still | 41 | // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still |
| 39 | // requires `IdentityProvider`, which `make` provides from | 42 | // requires `IdentityProvider`, which `make` provides from |
| 40 | // the resolved identity seam. | 43 | // the resolved identity seam. |
| 41 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns | 44 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns |
| 42 | // dispatch (create + forward + ownership) and builds its | 45 | // 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 the | 49 | // - 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 auth | 133 | * 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 Auth | 134 | * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth |
| 128 | * instance provided; it still requires `IdentityProvider` from the resolved | 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 | * the app wires into shutdown. | 137 | * the app wires into shutdown. |
| 131 | */ | 138 | */ |
| 132 | export const makeSelfHostMcpSeams = ( | 139 | export 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, |
| @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; · 2 unmodified lines | |||
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { afterAll, expect, test } from "@effect/vitest"; | 5 | import { afterAll, expect, test } from "@effect/vitest"; |
| 6 | import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | ||
| 6 | 7 | ||
| 7 | import { mintInviteCode } from "../testing/mint-invite"; | 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 | expect(JSON.stringify(await call.json())).toContain("42"); | 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 | test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { | 125 | test("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"); |
| @@ -1,7 +1,11 @@ | |||
| 1 | import { Layer } from "effect"; | 1 | import { Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; | 3 | import { |
| 4 | import type { McpErrorReporter } from "@executor-js/host-mcp"; | 4 | makeConsoleMcpErrorReporter, |
| 5 | makeMcpBuildServer, | ||
| 6 | makeMcpBuildServerV2, | ||
| 7 | } from "@executor-js/api/server"; | ||
| 8 | import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; | ||
| 5 | import { | 9 | import { |
| 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. */ | ||
| 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 | /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ | 74 | /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ |
| 55 | export const selfHostMcpSessions = inMemoryMcpSessionsLayer; | 75 | export const selfHostMcpSessions = inMemoryMcpSessionsLayer; |
| 56 | 76 | ||
| @@ -28,6 +28,7 @@ import { · 27 unmodified lines | |||
| 28 | import executorConfig from "../../executor.config"; | 28 | import executorConfig from "../../executor.config"; |
| 29 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; | 29 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; |
| 30 | import { | 30 | import { |
| 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 }, |
| @@ -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", |
| @@ -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 | import { Effect, type Cause } from "effect"; | 1 | import { Effect, type Cause } from "effect"; |
| 2 | import { | ||
| 3 | createMcpHandler, | ||
| 4 | isLegacyRequest, | ||
| 5 | type McpHttpHandler, | ||
| 6 | } from "@modelcontextprotocol/server"; | ||
| 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 7 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | 8 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 4 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | 9 | import { 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"; |
| 21 | import { | ||
| 22 | appsEnabledForClientCapabilities, | ||
| 23 | buildMcpServerV2, | ||
| 24 | clientCapabilitiesFromRequest, | ||
| 25 | requestBodyFromRequest, | ||
| 26 | } from "@executor-js/host-mcp/tool-server-v2"; | ||
| 16 | import { | 27 | import { |
| 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 | |||
| 295 | export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => { | 362 | export 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 | ||
| @@ -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", |
| @@ -58,7 +58,9 @@ export { · 57 unmodified lines | |||
| 58 | } from "./server/execution-stack"; | 58 | } from "./server/execution-stack"; |
| 59 | export { | 59 | export { |
| 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-author | 66 | // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author |
| @@ -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/sessions | 18 | // 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 AppLayer | 22 | // 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 whose | 133 | * 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 | */ |
| 135 | export interface McpProviders<RMcpAuth = never> { | 136 | interface 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. */ | ||
| 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 | * 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. The | 164 | * 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's | 564 | // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's |
| 551 | // build-time `McpAuthProvider` requirement (it registers a GET per | 565 | // 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 the | 568 | // 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 auth | 620 | * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth |
| 607 | * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides | 621 | * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides |
| 608 | * the complete identity seam ONCE (memoized) and shares it across the build-time | 622 | * the complete identity seam ONCE (memoized) and shares it across the build-time |
| 609 | * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single | 623 | * `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 Cloudflare | 639 | // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare |
| 626 | // Agent bridge), so mount only the auth-declared discovery routes. The discovery | 640 | // 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 | ||
| @@ -1,12 +1,17 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | 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 | import { | 8 | import { |
| 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"; |
| 9 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | 13 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 14 | import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; | ||
| 10 | import { | 15 | import { |
| 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. */ | ||
| 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 | /** Per-host (not per-session) MCP wiring. Kept separate from | 145 | /** 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. */ |
| 95 | export interface McpBuildHostOptions { | 147 | export interface McpBuildHostOptions { |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | |||
| 10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | ||
| 12 | import { describe, expect, it } from "@effect/vitest"; | 12 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | ||
| 13 | import { Cause, Effect, Layer, Ref } from "effect"; | 14 | import { Cause, Effect, Layer, Ref } from "effect"; |
| 14 | import { HttpRouter, HttpServer } from "effect/unstable/http"; | 15 | import { 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"; |
| 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 | const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; | 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 | roles: ["user"], | 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 | /** An auth provider that authenticates everything (so dispatch is reached). */ | 67 | /** An auth provider that authenticates everything (so dispatch is reached). */ |
| 43 | const AuthProviderLive = Layer.succeed(McpAuthProvider)({ | 68 | const 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 | ||
| 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 | it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { | 322 | it("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)({ |
| @@ -1,15 +1,29 @@ | |||
| 1 | import { Effect, Match, Predicate } from "effect"; | 1 | import { Effect, Match, Predicate } from "effect"; |
| 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 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 | import { | 10 | import { |
| 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"; |
| 22 | import { | ||
| 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 else | 44 | // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else |
| 31 | // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz | 45 | // — 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), NO | 49 | // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO |
| 36 | // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport | 50 | // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport |
| @@ -42,6 +56,14 @@ import { · 5 unmodified lines | |||
| 42 | 56 | ||
| 43 | const MCP_PATH = "/mcp"; | 57 | const MCP_PATH = "/mcp"; |
| 44 | const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; | 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 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ | 68 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 47 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); | 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 | * preflight against the metadata docs too (RFC 9728 discovery from a 401), so | 90 | * 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 | */ |
| 71 | const corsPreflightResponse = (): Response => | 93 | const 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 | ||
| 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 | /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ | 318 | /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ |
| 216 | const mcpDispatch = (resource: McpResource) => | 319 | const 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 other | 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 | 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 opens | 361 | // 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 a | 362 | // 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 up | 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 | * otherwise, since the envelope returns a `Response`) and rendered as a stable | 393 | * 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 | */ |
| 283 | const mcpRoute = (resource: McpResource) => | 396 | const 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 | ||
| 294 | const toolkitMcpRoute = Effect.gen(function* () { | 407 | const 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 merges | 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 | * 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`, so | 419 | * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so |
| @@ -310,16 +424,22 @@ const toolkitMcpRoute = Effect.gen(function* () { · 4 unmodified lines | |||
| 310 | export const McpServingRoutes = HttpRouter.use((router) => | 424 | export 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 | }), |
| @@ -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 | ||
| @@ -1,10 +1,11 @@ | |||
| 1 | import { Context, Effect, Layer, Schema } from "effect"; | 1 | import { Context, Effect, Layer, Schema } from "effect"; |
| 2 | import type { Cause } from "effect"; | 2 | import type { Cause } from "effect"; |
| 3 | import 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 TWO | 8 | // The shared MCP serving envelope (see `./envelope`) depends ONLY on these THREE |
| 8 | // seams. Each product (self-host, cloud, local) provides its own Layer | 9 | // seams. Each product (self-host, cloud, local) provides its own Layer |
| 9 | // satisfying the same tags; the envelope never changes. The seams are kept | 10 | // satisfying the same tags; the envelope never changes. The seams are kept |
| 10 | // deliberately small — anything provider-specific (Durable-Object trace | 11 | // 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 and | 13 | // 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 authorize | 17 | // 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/forwards | 20 | // 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-process | 25 | // 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 a | 27 | // 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. */ | ||
| 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 | // The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and | 310 | // 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 the | 311 | // renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the |
| @@ -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 a | 4 | * Neutral hosts call {@link buildMcpServerV2} from a `createMcpHandler` |
| 5 | * `createMcpHandler` `McpServerFactory`, once per request. The factory's | 5 | * `McpServerFactory`, once per request. The factory's |
| 6 | * `McpRequestContext.requestInfo` exposes the original HTTP request; the host | 6 | * `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 host | 9 | * separate host path. |
| 10 | * routing. | ||
| 11 | */ | 10 | */ |
| 12 | import { Effect, Match, Option, Schema } from "effect"; | 11 | import { Effect, Match, Option, Schema } from "effect"; |
| 13 | import * as Cause from "effect/Cause"; | 12 | import * as Cause from "effect/Cause"; |
| 14 | import { | 13 | import { |
| 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 | ||
| 29 | import { | 29 | import { |
| 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 | ||
| 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 | const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ | 127 | const 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 | ||
| 88 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 89 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 90 | |||
| 91 | const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => { | 133 | const 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; |