Unified · Split
| @@ -40,7 +40,6 @@ · 39 unmodified lines | ||
| 40 | 40 | "@executor-js/sdk": "workspace:*", |
| 41 | 41 | "@libsql/client": "catalog:", |
| 42 | 42 | "@libsql/kysely-libsql": "catalog:", |
| 43 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 44 | 43 | "@tanstack/react-router": "catalog:", |
| 45 | 44 | "better-auth": "^1.6.11", |
| 46 | 45 | "drizzle-orm": "catalog:", |
| @@ -23,8 +23,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 22 unmodified lines | ||
| 23 | 23 | // ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer` |
| 24 | 24 | // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host |
| 25 | 25 | // supplies only its fully-provided execution-stack layer (QuickJS over the |
| 26 | −// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the | |
| 27 | −// identical seam with its own stack layer. | |
| 26 | +// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the | |
| 27 | +// connection-lifetime SDK v2 assembly used by the shared store. | |
| 28 | 28 | // --------------------------------------------------------------------------- |
| 29 | 29 | |
| 30 | 30 | import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; |
| @@ -46,7 +46,6 @@ · 45 unmodified lines | ||
| 46 | 46 | "@executor-js/sdk": "workspace:*", |
| 47 | 47 | "@executor-js/vite-plugin": "workspace:*", |
| 48 | 48 | "@libsql/client": "catalog:", |
| 49 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 50 | 49 | "@modelcontextprotocol/server": "2.0.0", |
| 51 | 50 | "@tanstack/react-router": "catalog:", |
| 52 | 51 | "drizzle-orm": "catalog:", |
| @@ -57,6 +56,7 @@ · 4 unmodified lines | ||
| 57 | 56 | }, |
| 58 | 57 | "devDependencies": { |
| 59 | 58 | "@modelcontextprotocol/client": "2.0.0", |
| 59 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 60 | 60 | "@rhyssul/portless": "^0.13.0", |
| 61 | 61 | "@tailwindcss/vite": "catalog:", |
| 62 | 62 | "@tanstack/router-plugin": "^1.167.12", |
| @@ -0,0 +1,44 @@ | ||
| 1 | +import { Effect } from "effect"; | |
| 2 | + | |
| 3 | +import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; | |
| 4 | +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | |
| 5 | + | |
| 6 | +import { runMcpStdioServer } from "./mcp"; | |
| 7 | + | |
| 8 | +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); | |
| 9 | +const paused: Extract<ExecutionResult, { status: "paused" }> = { | |
| 10 | + status: "paused", | |
| 11 | + execution: { | |
| 12 | + id: "stdio-execution", | |
| 13 | + elicitationContext: { | |
| 14 | + address: TOOL_ADDRESS, | |
| 15 | + args: {}, | |
| 16 | + request: FormElicitation.make({ | |
| 17 | + message: "Approve the stdio action?", | |
| 18 | + requestedSchema: { | |
| 19 | + type: "object", | |
| 20 | + properties: { value: { type: "string" } }, | |
| 21 | + required: ["value"], | |
| 22 | + }, | |
| 23 | + }), | |
| 24 | + }, | |
| 25 | + }, | |
| 26 | +}; | |
| 27 | + | |
| 28 | +const engine: ExecutionEngine = { | |
| 29 | + execute: () => Effect.succeed({ result: "unused" }), | |
| 30 | + executeWithPause: (code) => | |
| 31 | + code === "needs approval" | |
| 32 | + ? Effect.succeed(paused) | |
| 33 | + : Effect.succeed({ status: "completed", result: { result: 4 } }), | |
| 34 | + resume: (_executionId, response) => | |
| 35 | + Effect.succeed({ status: "completed", result: { result: response.content?.value } }), | |
| 36 | + isExecutionSettled: () => Effect.succeed(false), | |
| 37 | + getPausedExecution: (executionId) => | |
| 38 | + Effect.succeed(executionId === paused.execution.id ? paused.execution : null), | |
| 39 | + pausedExecutionCount: () => Effect.succeed(1), | |
| 40 | + hasPausedExecutions: () => Effect.succeed(true), | |
| 41 | + getDescription: Effect.succeed("stdio integration test executor"), | |
| 42 | +}; | |
| 43 | + | |
| 44 | +await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } }); | |
| @@ -2,11 +2,11 @@ import { Effect, type Cause } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import { |
| 3 | 3 | createMcpHandler, |
| 4 | 4 | isLegacyRequest, |
| 5 | + McpServer, | |
| 6 | + WebStandardStreamableHTTPServerTransport, | |
| 5 | 7 | type McpHttpHandler, |
| 6 | 8 | } from "@modelcontextprotocol/server"; |
| 7 | −import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| 8 | −import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | |
| 9 | −import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | |
| 9 | +import { serveStdio } from "@modelcontextprotocol/server/stdio"; | |
| 10 | 10 | |
| 11 | 11 | import { |
| 12 | 12 | defaultMcpResource, |
| @@ -14,15 +14,12 @@ import { · 1 unmodified line | ||
| 14 | 14 | mcpResourceKey, |
| 15 | 15 | type McpResource, |
| 16 | 16 | } from "@executor-js/host-mcp"; |
| 17 | −import { | |
| 18 | − createExecutorMcpServer, | |
| 19 | − type ExecutorMcpServerConfig, | |
| 20 | −} from "@executor-js/host-mcp/tool-server"; | |
| 21 | 17 | import { |
| 22 | 18 | appsEnabledForClientCapabilities, |
| 23 | 19 | buildMcpServerV2, |
| 24 | 20 | clientCapabilitiesFromRequest, |
| 25 | 21 | requestBodyFromRequest, |
| 22 | + type ExecutorMcpServerConfig, | |
| 26 | 23 | } from "@executor-js/host-mcp/tool-server-v2"; |
| 27 | 24 | import { |
| 28 | 25 | approvalUrlForRequest, |
| @@ -280,10 +277,14 @@ export const createMcpRequestHandler = ( · 251 unmodified lines | ||
| 280 | 277 | const elicitationMode = readElicitationMode(request); |
| 281 | 278 | resourceConfig = await configForResource(resource); |
| 282 | 279 | created = await Effect.runPromise( |
| 283 | − createExecutorMcpServer({ | |
| 280 | + buildMcpServerV2({ | |
| 284 | 281 | ...resourceConfig.config, |
| 285 | 282 | browserApprovalStore: approvals.store, |
| 286 | 283 | artifactsEnabled: readArtifactsEnabled(request), |
| 284 | + appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false, | |
| 285 | + requestStateSigningKey: signingKey(), | |
| 286 | + requestStatePrincipal: "local", | |
| 287 | + sessionful: true, | |
| 287 | 288 | elicitationMode: |
| 288 | 289 | elicitationMode === "browser" |
| 289 | 290 | ? { |
| @@ -362,9 +363,18 @@ export const createMcpRequestHandler = ( · 72 unmodified lines | ||
| 362 | 363 | export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => { |
| 363 | 364 | startIntegrationsRefresh(); |
| 364 | 365 | |
| 365 | − // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy. | |
| 366 | − const server = await Effect.runPromise(createExecutorMcpServer(config)); | |
| 367 | − const transport = new StdioServerTransport(); | |
| 366 | + const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); | |
| 367 | + const stdio = serveStdio(() => | |
| 368 | + Effect.runPromise( | |
| 369 | + buildMcpServerV2({ | |
| 370 | + ...config, | |
| 371 | + appsEnabled: config.restoredAppsEnabled ?? false, | |
| 372 | + requestStateSigningKey, | |
| 373 | + requestStatePrincipal: "local", | |
| 374 | + sessionful: true, | |
| 375 | + }), | |
| 376 | + ), | |
| 377 | + ); | |
| 368 | 378 | |
| 369 | 379 | const waitForExit = () => |
| 370 | 380 | new Promise<void>((resolve) => { |
| @@ -383,10 +393,8 @@ export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promis · 12 unmodified lines | ||
| 383 | 393 | |
| 384 | 394 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources |
| 385 | 395 | try { |
| 386 | − await server.connect(transport); | |
| 387 | 396 | await waitForExit(); |
| 388 | 397 | } finally { |
| 389 | − await ignoreClose(() => transport.close()); | |
| 390 | − await ignoreClose(() => server.close()); | |
| 398 | + await ignoreClose(() => stdio.close()); | |
| 391 | 399 | } |
| 392 | 400 | }; |
| @@ -244,7 +244,6 @@ · 243 unmodified lines | ||
| 244 | 244 | "@executor-js/sdk": "workspace:*", |
| 245 | 245 | "@libsql/client": "catalog:", |
| 246 | 246 | "@libsql/kysely-libsql": "catalog:", |
| 247 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 248 | 247 | "@tanstack/react-router": "catalog:", |
| 249 | 248 | "better-auth": "^1.6.11", |
| 250 | 249 | "drizzle-orm": "catalog:", |
| @@ -301,7 +300,6 @@ · 50 unmodified lines | ||
| 301 | 300 | "@executor-js/sdk": "workspace:*", |
| 302 | 301 | "@executor-js/vite-plugin": "workspace:*", |
| 303 | 302 | "@libsql/client": "catalog:", |
| 304 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 305 | 303 | "@modelcontextprotocol/server": "2.0.0", |
| 306 | 304 | "@tanstack/react-router": "catalog:", |
| 307 | 305 | "drizzle-orm": "catalog:", |
| @@ -312,6 +310,7 @@ · 4 unmodified lines | ||
| 312 | 310 | }, |
| 313 | 311 | "devDependencies": { |
| 314 | 312 | "@modelcontextprotocol/client": "2.0.0", |
| 313 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 315 | 314 | "@rhyssul/portless": "^0.13.0", |
| 316 | 315 | "@tailwindcss/vite": "catalog:", |
| 317 | 316 | "@tanstack/router-plugin": "^1.167.12", |
| @@ -8,9 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | import { |
| 9 | 9 | McpEngineBuildError, |
| 10 | 10 | type McpBuildServer, |
| 11 | − type McpBuildServerOptions, | |
| 12 | 11 | } from "@executor-js/host-mcp/in-memory-session-store"; |
| 13 | −import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 14 | 12 | import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; |
| 15 | 13 | import { |
| 16 | 14 | artifactUrlFor, |
| @@ -25,14 +23,9 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 8 unmodified lines | ||
| 25 | 23 | // --------------------------------------------------------------------------- |
| 26 | 24 | // Shared in-process MCP host helpers. |
| 27 | 25 | // |
| 28 | −// Every host that serves MCP from one isolate (self-host, the Cloudflare QuickJS | |
| 29 | −// host) builds its per-session McpServer the same way — assemble the scoped | |
| 30 | −// engine via `makeExecutionStack`, wrap it with `createExecutorMcpServer` — and | |
| 31 | −// reports orchestration defects through the same console `ErrorCapture` seam. | |
| 32 | −// These two factories are the single home for that logic; a host supplies ONLY | |
| 33 | −// its fully-provided execution-stack layer and its `ErrorCapture` layer. The | |
| 34 | −// cross-isolate variant (cloud's Durable Object store) is the exception that | |
| 35 | −// builds its engine inside the DO. | |
| 26 | +// Neutral hosts build both sessionful legacy connections and stateless modern | |
| 27 | +// requests from the same SDK v2 assembly over a scoped execution stack. The | |
| 28 | +// Cloudflare Durable Object path remains a separate v1-backed composition root. | |
| 36 | 29 | // --------------------------------------------------------------------------- |
| 37 | 30 | |
| 38 | 31 | /** The five execution-stack seams a host fully provides (no residual). */ |
| @@ -42,18 +35,19 @@ export type McpExecutionStackLayer = Layer.Layer< · 3 unmodified lines | ||
| 42 | 35 | |
| 43 | 36 | /** |
| 44 | 37 | * Build the per-session MCP server factory over a host's execution stack: |
| 45 | − * `makeExecutionStack` → engine → `createExecutorMcpServer`. Hosts differ only | |
| 38 | + * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only | |
| 46 | 39 | * in the injected stack layer (libSQL vs D1, etc.). |
| 47 | 40 | */ |
| 48 | 41 | export const makeMcpBuildServer = |
| 49 | 42 | (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer => |
| 50 | − (principal: Principal, options?: McpBuildServerOptions) => | |
| 51 | − Effect.gen(function* () { | |
| 43 | + (principal: Principal, options) => { | |
| 44 | + const { resource, ...serverOptions } = options; | |
| 45 | + return Effect.gen(function* () { | |
| 52 | 46 | const { engine, executor } = yield* makeExecutionStack( |
| 53 | 47 | principal.accountId, |
| 54 | 48 | principal.organizationId, |
| 55 | 49 | principal.organizationName, |
| 56 | − { mcpResource: options?.resource }, | |
| 50 | + { mcpResource: resource }, | |
| 57 | 51 | ).pipe(Effect.withSpan("mcp.execution_stack.build")); |
| 58 | 52 | // Read inside the provided boundary: `webBaseUrl` is a host seam, and |
| 59 | 53 | // hosts that can't know their public URL at boot leave it unset — in |
| @@ -69,7 +63,7 @@ export const makeMcpBuildServer = · 9 unmodified lines | ||
| 69 | 63 | Effect.provide(executionStack), |
| 70 | 64 | Effect.mapError((cause) => new McpEngineBuildError({ cause })), |
| 71 | 65 | Effect.flatMap(({ engine, executor, webBaseUrl }) => |
| 72 | − createExecutorMcpServer({ | |
| 66 | + buildMcpServerV2({ | |
| 73 | 67 | engine, |
| 74 | 68 | artifacts: executor.artifacts, |
| 75 | 69 | connections: executor.connections, |
| @@ -87,13 +81,14 @@ export const makeMcpBuildServer = · 11 unmodified lines | ||
| 87 | 81 | ...(webBaseUrl |
| 88 | 82 | ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } |
| 89 | 83 | : {}), |
| 90 | − ...(options ?? {}), | |
| 84 | + ...serverOptions, | |
| 91 | 85 | }).pipe( |
| 92 | 86 | Effect.withSpan("mcp.server.create"), |
| 93 | 87 | Effect.map((mcpServer) => ({ mcpServer, engine })), |
| 94 | 88 | ), |
| 95 | 89 | ), |
| 96 | 90 | ); |
| 91 | + }; | |
| 97 | 92 | |
| 98 | 93 | /** Build function consumed by the neutral envelope's modern-server seam. */ |
| 99 | 94 | export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"]; |
| @@ -1,12 +1,20 @@ | ||
| 1 | −import { expect, it } from "@effect/vitest"; | |
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import { Effect } from "effect"; |
| 3 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 4 | +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 5 | +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | |
| 6 | + | |
| 7 | +import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; | |
| 8 | +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | |
| 3 | 9 | |
| 4 | 10 | import { |
| 5 | 11 | makeInMemoryMcpSessionStore, |
| 6 | 12 | McpEngineBuildError, |
| 7 | 13 | type McpBuildServerOptions, |
| 8 | 14 | } from "./in-memory-session-store"; |
| 15 | +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; | |
| 9 | 16 | import { defaultMcpResource, type Principal } from "./seams"; |
| 17 | +import { buildMcpServerV2 } from "./tool-server-v2"; | |
| 10 | 18 | |
| 11 | 19 | const TEST_PRINCIPAL: Principal = { |
| 12 | 20 | accountId: "acct_test", |
| @@ -18,37 +26,158 @@ const TEST_PRINCIPAL: Principal = { · 5 unmodified lines | ||
| 18 | 26 | roles: ["user"], |
| 19 | 27 | }; |
| 20 | 28 | |
| 21 | −it("preserves native elicitation mode when creating an in-memory MCP session", async () => { | |
| 22 | − let buildOptions: McpBuildServerOptions | undefined; | |
| 23 | − const sessions = makeInMemoryMcpSessionStore((_principal, options) => { | |
| 24 | − buildOptions = options; | |
| 25 | − return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" })); | |
| 29 | +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); | |
| 30 | + | |
| 31 | +const makeElicitingEngine = (): { | |
| 32 | + readonly engine: ExecutionEngine; | |
| 33 | + readonly resumedWith: () => ResumeResponse | undefined; | |
| 34 | +} => { | |
| 35 | + const request = FormElicitation.make({ | |
| 36 | + message: "Which value?", | |
| 37 | + requestedSchema: { | |
| 38 | + type: "object", | |
| 39 | + properties: { value: { type: "string" } }, | |
| 40 | + required: ["value"], | |
| 41 | + }, | |
| 26 | 42 | }); |
| 43 | + const paused: Extract<ExecutionResult, { status: "paused" }> = { | |
| 44 | + status: "paused", | |
| 45 | + execution: { | |
| 46 | + id: "execution-legacy", | |
| 47 | + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, | |
| 48 | + }, | |
| 49 | + }; | |
| 50 | + let resumedWith: ResumeResponse | undefined; | |
| 51 | + return { | |
| 52 | + engine: { | |
| 53 | + execute: () => Effect.succeed({ result: "unused" }), | |
| 54 | + executeWithPause: () => Effect.succeed(paused), | |
| 55 | + resume: (_executionId, response) => { | |
| 56 | + resumedWith = response; | |
| 57 | + return Effect.succeed({ | |
| 58 | + status: "completed", | |
| 59 | + result: { result: response.content?.value }, | |
| 60 | + }); | |
| 61 | + }, | |
| 62 | + isExecutionSettled: () => Effect.succeed(false), | |
| 63 | + getPausedExecution: (executionId) => | |
| 64 | + Effect.succeed(executionId === paused.execution.id ? paused.execution : null), | |
| 65 | + pausedExecutionCount: () => Effect.succeed(1), | |
| 66 | + hasPausedExecutions: () => Effect.succeed(true), | |
| 67 | + getDescription: Effect.succeed("store integration test executor"), | |
| 68 | + }, | |
| 69 | + resumedWith: () => resumedWith, | |
| 70 | + }; | |
| 71 | +}; | |
| 27 | 72 | |
| 28 | − const result = await Effect.runPromise( | |
| 29 | − sessions.store.dispatch({ | |
| 30 | − request: new Request("https://executor.test/mcp?elicitation_mode=native", { | |
| 31 | − method: "POST", | |
| 32 | − headers: { "content-type": "application/json" }, | |
| 33 | − body: JSON.stringify({ | |
| 34 | − jsonrpc: "2.0", | |
| 35 | − id: 1, | |
| 36 | − method: "initialize", | |
| 37 | − params: { | |
| 38 | − protocolVersion: "2025-06-18", | |
| 39 | − capabilities: { elicitation: { form: {} } }, | |
| 40 | − clientInfo: { name: "test-client", version: "1.0.0" }, | |
| 41 | − }, | |
| 73 | +describe("in-memory MCP session store", () => { | |
| 74 | + it("preserves native elicitation mode and supplies the v2 session inputs", async () => { | |
| 75 | + let buildOptions: McpBuildServerOptions | undefined; | |
| 76 | + const sessions = makeInMemoryMcpSessionStore((_principal, options) => { | |
| 77 | + buildOptions = options; | |
| 78 | + return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" })); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + const result = await Effect.runPromise( | |
| 82 | + sessions.store.dispatch({ | |
| 83 | + request: new Request("https://executor.test/mcp?elicitation_mode=native", { | |
| 84 | + method: "POST", | |
| 85 | + headers: { "content-type": "application/json" }, | |
| 86 | + body: JSON.stringify({ | |
| 87 | + jsonrpc: "2.0", | |
| 88 | + id: 1, | |
| 89 | + method: "initialize", | |
| 90 | + params: { | |
| 91 | + protocolVersion: "2025-06-18", | |
| 92 | + capabilities: { elicitation: { form: {} } }, | |
| 93 | + clientInfo: { name: "test-client", version: "1.0.0" }, | |
| 94 | + }, | |
| 95 | + }), | |
| 42 | 96 | }), |
| 97 | + principal: TEST_PRINCIPAL, | |
| 98 | + resource: defaultMcpResource, | |
| 99 | + sessionId: null, | |
| 100 | + method: "POST", | |
| 43 | 101 | }), |
| 44 | − principal: TEST_PRINCIPAL, | |
| 45 | − resource: defaultMcpResource, | |
| 46 | − sessionId: null, | |
| 47 | − method: "POST", | |
| 48 | − }), | |
| 49 | − ); | |
| 50 | − | |
| 51 | − expect(result).toBeInstanceOf(Response); | |
| 52 | − expect((result as Response).status).toBe(500); | |
| 53 | − expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); | |
| 102 | + ); | |
| 103 | + | |
| 104 | + expect(result).toBeInstanceOf(Response); | |
| 105 | + expect((result as Response).status).toBe(500); | |
| 106 | + expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); | |
| 107 | + expect(buildOptions).toMatchObject({ | |
| 108 | + appsEnabled: false, | |
| 109 | + requestStatePrincipal: `${TEST_PRINCIPAL.accountId}\u0000${TEST_PRINCIPAL.organizationId}`, | |
| 110 | + sessionful: true, | |
| 111 | + }); | |
| 112 | + expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array); | |
| 113 | + }); | |
| 114 | + | |
| 115 | + it("serves a legacy client through v2 with live Apps capabilities, elicitation, and reuse", async () => { | |
| 116 | + const { engine, resumedWith } = makeElicitingEngine(); | |
| 117 | + const sessions = makeInMemoryMcpSessionStore((_principal, options) => | |
| 118 | + buildMcpServerV2({ | |
| 119 | + engine, | |
| 120 | + ...options, | |
| 121 | + loadAppShellHtml: async () => "<html></html>", | |
| 122 | + }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))), | |
| 123 | + ); | |
| 124 | + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { | |
| 125 | + const request = | |
| 126 | + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); | |
| 127 | + const result = await Effect.runPromise( | |
| 128 | + sessions.store.dispatch({ | |
| 129 | + request, | |
| 130 | + principal: TEST_PRINCIPAL, | |
| 131 | + resource: defaultMcpResource, | |
| 132 | + sessionId: request.headers.get("mcp-session-id"), | |
| 133 | + method: request.method, | |
| 134 | + }), | |
| 135 | + ); | |
| 136 | + return result instanceof Response | |
| 137 | + ? result | |
| 138 | + : new Response(result === "forbidden" ? "Forbidden" : "Not found", { | |
| 139 | + status: result === "forbidden" ? 403 : 404, | |
| 140 | + }); | |
| 141 | + }; | |
| 142 | + const transport = new StreamableHTTPClientTransport( | |
| 143 | + new URL("https://executor.test/mcp?elicitation_mode=native"), | |
| 144 | + { fetch }, | |
| 145 | + ); | |
| 146 | + const client = new Client( | |
| 147 | + { name: "legacy-store-client", version: "1.0.0" }, | |
| 148 | + { | |
| 149 | + capabilities: { | |
| 150 | + elicitation: { form: {} }, | |
| 151 | + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, | |
| 152 | + }, | |
| 153 | + }, | |
| 154 | + ); | |
| 155 | + let elicitationRequests = 0; | |
| 156 | + client.setRequestHandler(ElicitRequestSchema, async (request) => { | |
| 157 | + elicitationRequests += 1; | |
| 158 | + expect(request.params).toMatchObject({ message: "Which value?" }); | |
| 159 | + return { action: "accept" as const, content: { value: "approved" } }; | |
| 160 | + }); | |
| 161 | + | |
| 162 | + await client.connect(transport); | |
| 163 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the sessionful client and store | |
| 164 | + try { | |
| 165 | + const tools = await client.listTools(); | |
| 166 | + expect(tools.tools.map(({ name }) => name)).toContain("execute"); | |
| 167 | + expect(tools.tools.map(({ name }) => name)).toContain("execute-action"); | |
| 168 | + | |
| 169 | + const result = await client.callTool({ | |
| 170 | + name: "execute", | |
| 171 | + arguments: { code: "await tools.test.approve()" }, | |
| 172 | + }); | |
| 173 | + expect(result.content).toEqual([{ type: "text", text: "approved" }]); | |
| 174 | + expect(result.isError).toBeFalsy(); | |
| 175 | + expect(elicitationRequests).toBe(1); | |
| 176 | + expect(resumedWith()).toEqual({ action: "accept", content: { value: "approved" } }); | |
| 177 | + expect(sessions.sessionCount()).toBe(1); | |
| 178 | + } finally { | |
| 179 | + await client.close(); | |
| 180 | + await sessions.close(); | |
| 181 | + } | |
| 182 | + }); | |
| 54 | 183 | }); |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { Cause, Data, Effect, Layer } from "effect"; |
| 2 | −import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| 3 | −import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | |
| 2 | +import { | |
| 3 | + type McpServer, | |
| 4 | + WebStandardStreamableHTTPServerTransport, | |
| 5 | +} from "@modelcontextprotocol/server"; | |
| 4 | 6 | |
| 5 | 7 | import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; |
| 6 | 8 | |
| @@ -26,7 +28,7 @@ import { · 19 unmodified lines | ||
| 26 | 28 | type Principal, |
| 27 | 29 | type McpResource, |
| 28 | 30 | } from "./seams"; |
| 29 | −import type { BrowserApprovalStore } from "./tool-server"; | |
| 31 | +import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server-v2"; | |
| 30 | 32 | |
| 31 | 33 | // --------------------------------------------------------------------------- |
| 32 | 34 | // In-process McpSessionStore — the single-node serving store, shared by every |
| @@ -75,12 +77,20 @@ export interface McpBuildServerOptions { · 42 unmodified lines | ||
| 75 | 77 | * with `?artifacts=false`; opted out, the built server registers none of |
| 76 | 78 | * the artifact tools, resource, or skills. */ |
| 77 | 79 | readonly artifactsEnabled?: boolean; |
| 80 | + /** The sessionful v2 assembly starts disabled and replaces this from initialize. */ | |
| 81 | + readonly appsEnabled: false; | |
| 82 | + /** Process-lifetime HMAC key for the v2 legacy-shim continuation state. */ | |
| 83 | + readonly requestStateSigningKey: Uint8Array; | |
| 84 | + /** Stable authenticated owner bound into continuation state. */ | |
| 85 | + readonly requestStatePrincipal: string; | |
| 86 | + /** Selects live negotiated capabilities instead of stateless request policy. */ | |
| 87 | + readonly sessionful: true; | |
| 78 | 88 | } |
| 79 | 89 | |
| 80 | 90 | /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ |
| 81 | 91 | export type McpBuildServer = ( |
| 82 | 92 | principal: Principal, |
| 83 | − options?: McpBuildServerOptions, | |
| 93 | + options: McpBuildServerOptions, | |
| 84 | 94 | ) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>; |
| 85 | 95 | |
| 86 | 96 | export interface InMemoryMcpSessionStore { |
| @@ -104,10 +114,17 @@ export interface InMemoryMcpSessionStore { · 17 unmodified lines | ||
| 104 | 114 | request: Request, |
| 105 | 115 | principal?: Principal, |
| 106 | 116 | ) => Promise<Response | null>; |
| 117 | + /** Number of live initialized sessions currently owned by this store. */ | |
| 118 | + readonly sessionCount: () => number; | |
| 107 | 119 | /** Dispose every live session — wire into the host's shutdown (not a seam). */ |
| 108 | 120 | readonly close: () => Promise<void>; |
| 109 | 121 | } |
| 110 | 122 | |
| 123 | +type McpRequestBuildOptions = Pick< | |
| 124 | + McpBuildServerOptions, | |
| 125 | + "artifactsEnabled" | "browserApprovalStore" | "elicitationMode" | |
| 126 | +>; | |
| 127 | + | |
| 111 | 128 | const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => |
| 112 | 129 | close |
| 113 | 130 | ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined }))) |
| @@ -162,6 +179,7 @@ export const makeInMemoryMcpSessionStore = ( · 48 unmodified lines | ||
| 162 | 179 | const owners = new Map<string, SessionOwner>(); |
| 163 | 180 | const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>(); |
| 164 | 181 | const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); |
| 182 | + const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); | |
| 165 | 183 | |
| 166 | 184 | const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { |
| 167 | 185 | const transport = transports.get(id); |
| @@ -222,7 +240,7 @@ export const makeInMemoryMcpSessionStore = ( · 54 unmodified lines | ||
| 222 | 240 | const buildOptionsFor = ( |
| 223 | 241 | request: Request, |
| 224 | 242 | sessionId: () => string | null, |
| 225 | − ): McpBuildServerOptions => { | |
| 243 | + ): McpRequestBuildOptions => { | |
| 226 | 244 | const artifactsEnabled = readArtifactsEnabled(request); |
| 227 | 245 | const mode = readElicitationMode(request); |
| 228 | 246 | if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } }; |
| @@ -253,12 +271,19 @@ export const makeInMemoryMcpSessionStore = ( · 24 unmodified lines | ||
| 253 | 271 | return buildServer(principal, { |
| 254 | 272 | ...buildOptionsFor(request, () => createdSessionId), |
| 255 | 273 | resource, |
| 274 | + appsEnabled: false, | |
| 275 | + requestStateSigningKey, | |
| 276 | + requestStatePrincipal: mcpRequestStatePrincipal(principal), | |
| 277 | + sessionful: true, | |
| 256 | 278 | }).pipe( |
| 257 | 279 | Effect.flatMap(({ mcpServer, engine }) => |
| 258 | 280 | Effect.gen(function* () { |
| 259 | 281 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| 260 | 282 | sessionIdGenerator: () => crypto.randomUUID(), |
| 261 | − enableJsonResponse: true, | |
| 283 | + // Native mode needs an open SSE response for the v2 legacy shim's | |
| 284 | + // server→client elicitation request. Other modes preserve the | |
| 285 | + // store's existing single-JSON response behavior. | |
| 286 | + enableJsonResponse: readElicitationMode(request) !== "native", | |
| 262 | 287 | onsessioninitialized: (sid) => { |
| 263 | 288 | createdSessionId = sid; |
| 264 | 289 | transports.set(sid, transport); |
| @@ -376,6 +401,7 @@ export const makeInMemoryMcpSessionStore = ( · 111 unmodified lines | ||
| 376 | 401 | store, |
| 377 | 402 | handlePausedRequest, |
| 378 | 403 | handleApprovalRequest, |
| 404 | + sessionCount: () => transports.size, | |
| 379 | 405 | close: async () => { |
| 380 | 406 | const ids = new Set([...transports.keys(), ...servers.keys()]); |
| 381 | 407 | await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); |
| @@ -5,11 +5,10 @@ · 4 unmodified lines | ||
| 5 | 5 | // its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` / |
| 6 | 6 | // `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`). |
| 7 | 7 | // |
| 8 | −// The executor TOOL factory (`createExecutorMcpServer` — the execute/resume | |
| 9 | −// tools, the elicitation/browser-approval bridge, the Zod input schemas) is a | |
| 10 | −// different center of gravity: a host's session store builds an `McpServer` | |
| 11 | −// from it. It lives behind the `@executor-js/host-mcp/tool-server` subpath so | |
| 12 | −// the serving surface stays small and dependency-light. | |
| 8 | +// The executor tool assemblies (execute/resume tools, elicitation and browser | |
| 9 | +// approval bridges, Zod input schemas) are a different center of gravity. They | |
| 10 | +// live behind the `tool-server` and `tool-server-v2` subpaths so this serving | |
| 11 | +// surface stays small and dependency-light. | |
| 13 | 12 | // --------------------------------------------------------------------------- |
| 14 | 13 | |
| 15 | 14 | export { |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { Client as ModernClient } from "@modelcontextprotocol/client"; | |
| 3 | +import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio"; | |
| 2 | 4 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 3 | 5 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 6 | +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | |
| 4 | 7 | import { Effect } from "effect"; |
| 5 | 8 | import { mkdtempSync } from "node:fs"; |
| 6 | 9 | import { tmpdir } from "node:os"; |
| @@ -9,22 +12,25 @@ import { join, resolve } from "node:path"; · 2 unmodified lines | ||
| 9 | 12 | const repoRoot = resolve(import.meta.dirname, "../../../.."); |
| 10 | 13 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); |
| 11 | 14 | const testScope = resolve(repoRoot, "apps/local"); |
| 15 | +const stdioServerEntry = resolve(repoRoot, "apps/local/src/mcp-stdio-test-server.ts"); | |
| 16 | +const stdioServer = { | |
| 17 | + command: "bun", | |
| 18 | + args: ["run", stdioServerEntry], | |
| 19 | +}; | |
| 12 | 20 | |
| 13 | 21 | describe("MCP stdio integration", () => { |
| 14 | 22 | it.effect( |
| 15 | − "execute tool returns result over stdio transport", | |
| 23 | + "execute tool returns result over the CLI stdio bridge", | |
| 16 | 24 | () => |
| 17 | 25 | Effect.gen(function* () { |
| 18 | 26 | // Fresh temp dir so the test doesn't migrate against the developer's |
| 19 | 27 | // real ~/.executor/data.db. |
| 20 | 28 | const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-")); |
| 21 | − | |
| 22 | 29 | const transport = new StdioClientTransport({ |
| 23 | 30 | command: "bun", |
| 24 | 31 | args: ["run", cliEntry, "mcp", "--scope", testScope], |
| 25 | 32 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, |
| 26 | 33 | }); |
| 27 | − | |
| 28 | 34 | const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); |
| 29 | 35 | |
| 30 | 36 | yield* Effect.acquireRelease( |
| @@ -33,7 +39,7 @@ describe("MCP stdio integration", () => { · 2 unmodified lines | ||
| 33 | 39 | ); |
| 34 | 40 | |
| 35 | 41 | const { tools } = yield* Effect.promise(() => client.listTools()); |
| 36 | − expect(tools.map((t) => t.name)).toContain("execute"); | |
| 42 | + expect(tools.map(({ name }) => name)).toContain("execute"); | |
| 37 | 43 | |
| 38 | 44 | const result = yield* Effect.promise(() => |
| 39 | 45 | client.callTool({ |
| @@ -48,4 +54,77 @@ describe("MCP stdio integration", () => { · 8 unmodified lines | ||
| 48 | 54 | }).pipe(Effect.scoped), |
| 49 | 55 | { timeout: 30_000 }, |
| 50 | 56 | ); |
| 57 | + | |
| 58 | + it.effect( | |
| 59 | + "serves a legacy client and completes the native elicitation round-trip", | |
| 60 | + () => | |
| 61 | + Effect.gen(function* () { | |
| 62 | + const transport = new StdioClientTransport(stdioServer); | |
| 63 | + const client = new Client( | |
| 64 | + { name: "legacy-stdio-test-client", version: "1.0.0" }, | |
| 65 | + { capabilities: { elicitation: { form: {} } } }, | |
| 66 | + ); | |
| 67 | + let elicitationRequests = 0; | |
| 68 | + client.setRequestHandler(ElicitRequestSchema, async (request) => { | |
| 69 | + elicitationRequests += 1; | |
| 70 | + expect(request.params).toMatchObject({ message: "Approve the stdio action?" }); | |
| 71 | + return { action: "accept" as const, content: { value: "approved" } }; | |
| 72 | + }); | |
| 73 | + | |
| 74 | + yield* Effect.acquireRelease( | |
| 75 | + Effect.promise(() => client.connect(transport)), | |
| 76 | + () => Effect.promise(() => transport.close()), | |
| 77 | + ); | |
| 78 | + | |
| 79 | + const { tools } = yield* Effect.promise(() => client.listTools()); | |
| 80 | + expect(tools.map((t) => t.name)).toContain("execute"); | |
| 81 | + | |
| 82 | + const result = yield* Effect.promise(() => | |
| 83 | + client.callTool({ | |
| 84 | + name: "execute", | |
| 85 | + arguments: { code: "needs approval" }, | |
| 86 | + }), | |
| 87 | + ); | |
| 88 | + | |
| 89 | + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; | |
| 90 | + expect(text).toContain("approved"); | |
| 91 | + expect(result.isError).toBeFalsy(); | |
| 92 | + expect(elicitationRequests).toBe(1); | |
| 93 | + }).pipe(Effect.scoped), | |
| 94 | + { timeout: 30_000 }, | |
| 95 | + ); | |
| 96 | + | |
| 97 | + it.effect( | |
| 98 | + "serves a modern-pinned client over the same v2 stdio entry", | |
| 99 | + () => | |
| 100 | + Effect.gen(function* () { | |
| 101 | + const transport = new ModernStdioClientTransport(stdioServer); | |
| 102 | + const client = new ModernClient( | |
| 103 | + { name: "modern-stdio-test-client", version: "1.0.0" }, | |
| 104 | + { | |
| 105 | + capabilities: {}, | |
| 106 | + versionNegotiation: { mode: { pin: "2026-07-28" } }, | |
| 107 | + }, | |
| 108 | + ); | |
| 109 | + | |
| 110 | + yield* Effect.acquireRelease( | |
| 111 | + Effect.promise(() => client.connect(transport)), | |
| 112 | + () => Effect.promise(() => transport.close()), | |
| 113 | + ); | |
| 114 | + | |
| 115 | + const { tools } = yield* Effect.promise(() => client.listTools()); | |
| 116 | + expect(tools.map(({ name }) => name)).toContain("execute"); | |
| 117 | + | |
| 118 | + const result = yield* Effect.promise(() => | |
| 119 | + client.callTool({ | |
| 120 | + name: "execute", | |
| 121 | + arguments: { code: "return 2+2" }, | |
| 122 | + }), | |
| 123 | + ); | |
| 124 | + | |
| 125 | + expect(result.content).toEqual([{ type: "text", text: "4" }]); | |
| 126 | + expect(result.isError).toBeFalsy(); | |
| 127 | + }).pipe(Effect.scoped), | |
| 128 | + { timeout: 30_000 }, | |
| 129 | + ); | |
| 51 | 130 | }); |
| @@ -2152,7 +2152,7 @@ export const createExecutorMcpServerAssembly = < · 2151 unmodified lines | ||
| 2152 | 2152 | // persisting an absent-capability reading would make a downgrade durable |
| 2153 | 2153 | // for every future restore of the session. |
| 2154 | 2154 | const onAppsEnabledChange = config.onAppsEnabledChange; |
| 2155 | − if (assembly.era === "v1" && clientCapabilities && changed && onAppsEnabledChange) { | |
| 2155 | + if (clientCapabilities && changed && onAppsEnabledChange) { | |
| 2156 | 2156 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session |
| 2157 | 2157 | void Effect.runPromiseWith(context)( |
| 2158 | 2158 | onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })), |
| @@ -1,12 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | − * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era. | |
| 3 | − * | |
| 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. | |
| 2 | + * MCP SDK v2 assembly shared by stateless modern requests and sessionful | |
| 3 | + * connections. Stateless callers supply request-scoped capability policy; | |
| 4 | + * sessionful callers register the full surface once and read negotiated client | |
| 5 | + * capabilities from the live server. | |
| 10 | 6 | */ |
| 11 | 7 | import { Data, Effect, Match, Option, Schema } from "effect"; |
| 12 | 8 | import * as Cause from "effect/Cause"; |
| @@ -46,6 +42,8 @@ import { · 33 unmodified lines | ||
| 46 | 42 | type NativeExecutionServices, |
| 47 | 43 | } from "./tool-server-shared"; |
| 48 | 44 | |
| 45 | +export type { BrowserApprovalStore, ExecutorMcpServerConfig } from "./tool-server-shared"; | |
| 46 | + | |
| 49 | 47 | const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; |
| 50 | 48 | |
| 51 | 49 | const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); |
| @@ -57,11 +55,17 @@ type V2RequestContext = McpRequestJoinKeys & { · 5 unmodified lines | ||
| 57 | 55 | readonly serverContext: ServerContext; |
| 58 | 56 | }; |
| 59 | 57 | |
| 60 | −/** Additional request-scoped inputs required by the SDK v2 assembly. */ | |
| 58 | +/** Additional serving inputs required by the SDK v2 assembly. */ | |
| 61 | 59 | export type ExecutorMcpServerV2Config<E extends Cause.YieldableError = Cause.YieldableError> = |
| 62 | 60 | ExecutorMcpServerConfig<E> & { |
| 63 | − /** Whether this request's client can render MCP Apps resources. */ | |
| 61 | + /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */ | |
| 64 | 62 | readonly appsEnabled: boolean; |
| 63 | + /** | |
| 64 | + * Register a connection-lifetime server whose capability-dependent behavior | |
| 65 | + * follows the live initialize-negotiated state. Omitted for stateless modern | |
| 66 | + * request factories, which keep using {@link appsEnabled} as fixed policy. | |
| 67 | + */ | |
| 68 | + readonly sessionful?: boolean; | |
| 65 | 69 | /** HMAC key used to sign opaque native-elicitation continuation state. */ |
| 66 | 70 | readonly requestStateSigningKey: Uint8Array | string; |
| 67 | 71 | /** |
| @@ -124,16 +128,9 @@ export const verifyNativeRequestState = (input: { · 56 unmodified lines | ||
| 124 | 128 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 125 | 129 | typeof value === "object" && value !== null && !Array.isArray(value); |
| 126 | 130 | |
| 127 | −/** Parse the MCP Apps capability subset from an already-decoded modern body. */ | |
| 128 | −export const clientCapabilitiesFromRequestBody = ( | |
| 129 | − body: unknown, | |
| 131 | +const appsClientCapabilitiesFromUnknown = ( | |
| 132 | + capabilities: unknown, | |
| 130 | 133 | ): McpAppsClientCapabilities | null => { |
| 131 | − if (!isRecord(body)) return null; | |
| 132 | − const params = body.params; | |
| 133 | − if (!isRecord(params)) return null; | |
| 134 | − const metadata = params._meta; | |
| 135 | − if (!isRecord(metadata)) return null; | |
| 136 | − const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY]; | |
| 137 | 134 | if (!isRecord(capabilities)) return null; |
| 138 | 135 | const extensions = capabilities.extensions; |
| 139 | 136 | if (!isRecord(extensions)) return null; |
| @@ -147,6 +144,33 @@ export const clientCapabilitiesFromRequestBody = ( · 7 unmodified lines | ||
| 147 | 144 | return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; |
| 148 | 145 | }; |
| 149 | 146 | |
| 147 | +const elicitationSupportFromUnknown = ( | |
| 148 | + capabilities: unknown, | |
| 149 | +): { readonly form: boolean; readonly url: boolean } => { | |
| 150 | + if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) { | |
| 151 | + return { form: false, url: false }; | |
| 152 | + } | |
| 153 | + const elicitation = capabilities.elicitation; | |
| 154 | + const hasExplicitModes = "form" in elicitation || "url" in elicitation; | |
| 155 | + return { | |
| 156 | + form: hasExplicitModes ? Boolean(elicitation.form) : true, | |
| 157 | + url: Boolean(elicitation.url), | |
| 158 | + }; | |
| 159 | +}; | |
| 160 | + | |
| 161 | +/** Parse the MCP Apps capability subset from an already-decoded modern body. */ | |
| 162 | +export const clientCapabilitiesFromRequestBody = ( | |
| 163 | + body: unknown, | |
| 164 | +): McpAppsClientCapabilities | null => { | |
| 165 | + if (!isRecord(body)) return null; | |
| 166 | + const params = body.params; | |
| 167 | + if (!isRecord(params)) return null; | |
| 168 | + const metadata = params._meta; | |
| 169 | + if (!isRecord(metadata)) return null; | |
| 170 | + const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY]; | |
| 171 | + return appsClientCapabilitiesFromUnknown(capabilities); | |
| 172 | +}; | |
| 173 | + | |
| 150 | 174 | /** Parse a cloned HTTP request body without consuming the request itself. */ |
| 151 | 175 | export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> => |
| 152 | 176 | Effect.tryPromise({ |
| @@ -247,6 +271,10 @@ const missingNativeExecution = (executionId: string): McpToolResult => ({ · 94 unmodified lines | ||
| 247 | 271 | const createV2Assembly = <E extends Cause.YieldableError>( |
| 248 | 272 | config: ExecutorMcpServerV2Config<E>, |
| 249 | 273 | ): ExecutorMcpAssembly<McpServer, V2RequestContext> => { |
| 274 | + const sessionful = config.sessionful ?? false; | |
| 275 | + const initialAppsEnabled = sessionful | |
| 276 | + ? (config.restoredAppsEnabled ?? config.appsEnabled) | |
| 277 | + : config.appsEnabled; | |
| 250 | 278 | const requestStateCodec = createRequestStateCodec<NativeRequestState>({ |
| 251 | 279 | key: config.requestStateSigningKey, |
| 252 | 280 | ...(config.requestStateTtlSeconds === undefined |
| @@ -286,7 +314,7 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 33 unmodified lines | ||
| 286 | 314 | ) => { |
| 287 | 315 | const inputSchema = z.object(toolConfig.inputSchema); |
| 288 | 316 | const metadata = normalizedAppMetadata(toolConfig._meta); |
| 289 | − if (!config.appsEnabled && visibilityIncludes(metadata, "model")) { | |
| 317 | + if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) { | |
| 290 | 318 | const plainMetadata = withoutAppMetadata(metadata); |
| 291 | 319 | return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( |
| 292 | 320 | name, |
| @@ -330,15 +358,26 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 37 unmodified lines | ||
| 330 | 358 | return { |
| 331 | 359 | server, |
| 332 | 360 | era: "v2", |
| 333 | − initialAppsEnabled: config.appsEnabled, | |
| 334 | − getClientCapabilities: () => null, | |
| 335 | − getElicitationSupport: () => ({ form: true, url: true }), | |
| 336 | − getUiCapability: () => (config.appsEnabled ? { mimeTypes: [RESOURCE_MIME_TYPE] } : undefined), | |
| 337 | − onInitialized: () => undefined, | |
| 361 | + initialAppsEnabled, | |
| 362 | + getClientCapabilities: () => | |
| 363 | + sessionful ? (server.server.getClientCapabilities() ?? null) : null, | |
| 364 | + getElicitationSupport: () => | |
| 365 | + sessionful | |
| 366 | + ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) | |
| 367 | + : { form: true, url: true }, | |
| 368 | + getUiCapability: () => | |
| 369 | + sessionful | |
| 370 | + ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities())) | |
| 371 | + : config.appsEnabled | |
| 372 | + ? { mimeTypes: [RESOURCE_MIME_TYPE] } | |
| 373 | + : undefined, | |
| 374 | + onInitialized: (callback) => { | |
| 375 | + if (sessionful) server.server.oninitialized = callback; | |
| 376 | + }, | |
| 338 | 377 | registerTool, |
| 339 | 378 | registerAppTool: registerApp, |
| 340 | 379 | registerAppResource: (name, uri, resourceConfig, callback) => { |
| 341 | − if (!config.appsEnabled) return; | |
| 380 | + if (!sessionful && !config.appsEnabled) return; | |
| 342 | 381 | registerAppResource(server, name, uri, resourceConfig, async () => { |
| 343 | 382 | const result = await callback(); |
| 344 | 383 | return { contents: [...result.contents] }; |
| @@ -402,10 +441,11 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 57 unmodified lines | ||
| 402 | 441 | }; |
| 403 | 442 | |
| 404 | 443 | /** |
| 405 | − * Build one stateless SDK v2 Executor MCP server for a modern request. | |
| 444 | + * Build one SDK v2 Executor MCP server. | |
| 406 | 445 | * |
| 407 | − * Hosts must reuse the signing key across every request that can participate | |
| 408 | − * in the same native-elicitation continuation flow. | |
| 446 | + * Stateless hosts must reuse the signing key across every request that can | |
| 447 | + * participate in the same native-elicitation continuation flow. Sessionful | |
| 448 | + * hosts keep one instance connected and may use a connection-lifetime key. | |
| 409 | 449 | */ |
| 410 | 450 | export const buildMcpServerV2 = <E extends Cause.YieldableError>( |
| 411 | 451 | config: ExecutorMcpServerV2Config<E>, |