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