Unified · Split
| @@ -6,7 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug |
| 7 | 7 | // here; see db/db.schema.test.ts) |
| 8 | 8 | // - `createExecutionEngine` with an in-process code executor |
| 9 | −// - `createExecutorMcpServer` for the MCP request surface | |
| 9 | +// - `buildMcpServer` for the MCP request surface | |
| 10 | 10 | // - Real `@modelcontextprotocol/sdk` Client → server round-trips |
| 11 | 11 | // |
| 12 | 12 | // This test replicates the DO's init path (minus the WorkerTransport and |
| @@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; · 9 unmodified lines | ||
| 22 | 22 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 23 | 23 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; |
| 24 | 24 | |
| 25 | −import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 25 | +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 26 | 26 | import { createExecutionEngine } from "@executor-js/execution"; |
| 27 | 27 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 28 | 28 | import { collectTables } from "@executor-js/api/server"; |
| @@ -138,8 +138,12 @@ const openSession = ( · 109 unmodified lines | ||
| 138 | 138 | Effect.gen(function* () { |
| 139 | 139 | const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); |
| 140 | 140 | const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); |
| 141 | − const mcpServer = yield* createExecutorMcpServer({ | |
| 141 | + const mcpServer = yield* buildMcpServer({ | |
| 142 | 142 | engine, |
| 143 | + appsEnabled: false, | |
| 144 | + requestStateSigningKey: new Uint8Array(32).fill(23), | |
| 145 | + requestStatePrincipal: `cloud-mcp-test:${organizationId}`, | |
| 146 | + sessionful: true, | |
| 143 | 147 | elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined, |
| 144 | 148 | }); |
| 145 | 149 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | type AuthOutcome, |
| 10 | 10 | type McpResource, |
| 11 | 11 | } from "@executor-js/host-mcp"; |
| 12 | −import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; | |
| 12 | +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; | |
| 13 | 13 | import { |
| 14 | 14 | currentPropagationHeaders, |
| 15 | 15 | readArtifactsEnabled, |
| @@ -23,10 +23,11 @@ import postgres, { type Sql } from "postgres"; · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | import { |
| 25 | 25 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 26 | + buildMcpServer, | |
| 27 | + mcpRequestStatePrincipal, | |
| 26 | 28 | type PausedExecutionHooks, |
| 27 | 29 | type ResumeFallbackOutcome, |
| 28 | 30 | } from "@executor-js/host-mcp/tool-server"; |
| 29 | −import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; | |
| 30 | 31 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; |
| 31 | 32 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 32 | 33 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| @@ -232,7 +233,7 @@ const makeCloudModernRuntime = ( · 199 unmodified lines | ||
| 232 | 233 | ): BuiltModernMcpRuntime => ({ |
| 233 | 234 | engine: runtime.engine, |
| 234 | 235 | buildServer: (options) => |
| 235 | − buildMcpServerV2({ | |
| 236 | + buildMcpServer({ | |
| 236 | 237 | engine: runtime.engine, |
| 237 | 238 | description: runtime.description, |
| 238 | 239 | artifacts: runtime.executor.artifacts, |
| @@ -270,7 +271,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 31 unmodified lines | ||
| 270 | 271 | return server; |
| 271 | 272 | }; |
| 272 | 273 | |
| 273 | −/** Build one worker-side stateless SDK v2 server over a fresh cloud runtime. */ | |
| 274 | +/** Build one worker-side stateless MCP server over a fresh cloud runtime. */ | |
| 274 | 275 | export const makeCloudModernMcpServerBuilder = ( |
| 275 | 276 | session: McpSessionInit, |
| 276 | 277 | ): McpModernServerBuilder["Service"] => ({ |
| @@ -366,7 +367,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 89 unmodified lines | ||
| 366 | 367 | parentSpan: () => self.currentParentSpan(), |
| 367 | 368 | }); |
| 368 | 369 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 369 | − const mcpServer = yield* buildMcpServerV2({ | |
| 370 | + const mcpServer = yield* buildMcpServer({ | |
| 370 | 371 | engine, |
| 371 | 372 | description, |
| 372 | 373 | artifacts: executor.artifacts, |
| @@ -411,7 +412,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 38 unmodified lines | ||
| 411 | 412 | }), |
| 412 | 413 | } |
| 413 | 414 | : { mode: sessionElicitationMode }, |
| 414 | − }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServerV2")); | |
| 415 | + }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer")); | |
| 415 | 416 | return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; |
| 416 | 417 | }).pipe( |
| 417 | 418 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type AuthOutcome, |
| 8 | 8 | type Principal, |
| 9 | 9 | } from "@executor-js/host-mcp"; |
| 10 | −import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; | |
| 10 | +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; | |
| 11 | 11 | import { |
| 12 | 12 | currentPropagationHeaders, |
| 13 | 13 | readArtifactsEnabled, |
| @@ -2,10 +2,11 @@ import { Data, Effect } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 5 | + buildMcpServer, | |
| 6 | + mcpRequestStatePrincipal, | |
| 5 | 7 | type PausedExecutionHooks, |
| 6 | 8 | type ResumeFallbackOutcome, |
| 7 | 9 | } from "@executor-js/host-mcp/tool-server"; |
| 8 | −import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; | |
| 9 | 10 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; |
| 10 | 11 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 11 | 12 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| @@ -101,7 +102,7 @@ const makeCloudflareModernRuntime = ( · 89 unmodified lines | ||
| 101 | 102 | return { |
| 102 | 103 | engine: runtime.engine, |
| 103 | 104 | buildServer: (options) => |
| 104 | − buildMcpServerV2({ | |
| 105 | + buildMcpServer({ | |
| 105 | 106 | engine: runtime.engine, |
| 106 | 107 | description: runtime.description, |
| 107 | 108 | artifacts: runtime.executor.artifacts, |
| @@ -137,7 +138,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 29 unmodified lines | ||
| 137 | 138 | return server; |
| 138 | 139 | }; |
| 139 | 140 | |
| 140 | −/** Build the worker-side SDK v2 server over a fresh D1 execution runtime. */ | |
| 141 | +/** Build the worker-side MCP server over a fresh D1 execution runtime. */ | |
| 141 | 142 | export const makeCloudflareModernMcpServerBuilder = ( |
| 142 | 143 | env: CloudflareEnv, |
| 143 | 144 | config: CloudflareConfig, |
| @@ -268,7 +269,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 124 unmodified lines | ||
| 268 | 269 | // only offered when this deployment knows its own public URL. Without one |
| 269 | 270 | // the artifact is still saved, and the tool says so. |
| 270 | 271 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; |
| 271 | − const mcpServer = yield* buildMcpServerV2({ | |
| 272 | + const mcpServer = yield* buildMcpServer({ | |
| 272 | 273 | engine, |
| 273 | 274 | description, |
| 274 | 275 | artifacts: executor.artifacts, |
| @@ -44,7 +44,7 @@ export { · 43 unmodified lines | ||
| 44 | 44 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns |
| 45 | 45 | // dispatch (create + forward + ownership) and builds its |
| 46 | 46 | // engine internally over the shared SelfHostDb. |
| 47 | −// - McpModernServerBuilder -> one stateless SDK v2 server per request over | |
| 47 | +// - McpModernServerBuilder -> one stateless MCP server per request over | |
| 48 | 48 | // the same scoped execution stack and tool config. |
| 49 | 49 | // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the |
| 50 | 50 | // host's console capture. |
| @@ -58,7 +58,7 @@ export interface SelfHostMcpSeams { · 7 unmodified lines | ||
| 58 | 58 | readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>; |
| 59 | 59 | /** The in-process session store seam (dispatch + lifetime). */ |
| 60 | 60 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 61 | − /** Stateless SDK v2 server construction for modern requests. */ | |
| 61 | + /** Stateless MCP server construction for modern requests. */ | |
| 62 | 62 | readonly modern: Layer.Layer<McpModernServerBuilder>; |
| 63 | 63 | /** Route 500 defects through the host's console `ErrorCapture`. */ |
| 64 | 64 | readonly reporter: Layer.Layer<McpErrorReporter>; |
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | import { Layer } from "effect"; |
| 2 | 2 | |
| 3 | −import { | |
| 4 | − makeConsoleMcpErrorReporter, | |
| 5 | − makeMcpBuildServer, | |
| 6 | − makeMcpBuildServerV2, | |
| 7 | −} from "@executor-js/api/server"; | |
| 3 | +import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; | |
| 8 | 4 | import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; |
| 9 | 5 | import { |
| 10 | 6 | inMemoryMcpSessionsLayer, |
| @@ -24,7 +20,7 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 13 unmodified lines | ||
| 24 | 20 | // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host |
| 25 | 21 | // supplies only its fully-provided execution-stack layer (QuickJS over the |
| 26 | 22 | // long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the |
| 27 | −// connection-lifetime SDK v2 assembly used by the shared store. | |
| 23 | +// connection-lifetime assembly used by the shared store. | |
| 28 | 24 | // --------------------------------------------------------------------------- |
| 29 | 25 | |
| 30 | 26 | import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; |
| @@ -55,12 +51,12 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines | ||
| 55 | 51 | { webBaseUrl }, |
| 56 | 52 | ); |
| 57 | 53 | |
| 58 | −/** Build the stateless SDK v2 server seam over the same self-host stack/config. */ | |
| 54 | +/** Build the stateless MCP server seam over the same self-host stack/config. */ | |
| 59 | 55 | export const makeSelfHostMcpModernServerBuilder = ( |
| 60 | 56 | db: SelfHostDbHandle, |
| 61 | 57 | ): Layer.Layer<McpModernServerBuilder> => |
| 62 | 58 | Layer.succeed(McpModernServerBuilder)({ |
| 63 | − build: makeMcpBuildServerV2( | |
| 59 | + build: makeMcpBuildServer( | |
| 64 | 60 | SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), |
| 65 | 61 | { |
| 66 | 62 | loadAppShellHtml: loadMcpAppsShellHtml, |
| @@ -16,11 +16,11 @@ import { · 15 unmodified lines | ||
| 16 | 16 | } from "@executor-js/host-mcp"; |
| 17 | 17 | import { |
| 18 | 18 | appsEnabledForClientCapabilities, |
| 19 | − buildMcpServerV2, | |
| 19 | + buildMcpServer, | |
| 20 | 20 | clientCapabilitiesFromRequest, |
| 21 | 21 | requestBodyFromRequest, |
| 22 | − type ExecutorMcpServerConfig, | |
| 23 | −} from "@executor-js/host-mcp/tool-server-v2"; | |
| 22 | + type ExecutorMcpToolConfig, | |
| 23 | +} from "@executor-js/host-mcp/tool-server"; | |
| 24 | 24 | import { |
| 25 | 25 | approvalUrlForRequest, |
| 26 | 26 | decodeResumeResponse, |
| @@ -53,12 +53,12 @@ export type McpRequestHandler = { · 26 unmodified lines | ||
| 53 | 53 | }; |
| 54 | 54 | |
| 55 | 55 | export interface LocalMcpServerConfig { |
| 56 | − readonly config: ExecutorMcpServerConfig; | |
| 56 | + readonly config: ExecutorMcpToolConfig; | |
| 57 | 57 | readonly close?: () => Promise<void>; |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | 60 | export interface LocalMcpRequestHandlerConfig { |
| 61 | − readonly defaultConfig: ExecutorMcpServerConfig; | |
| 61 | + readonly defaultConfig: ExecutorMcpToolConfig; | |
| 62 | 62 | readonly createConfigForResource?: ( |
| 63 | 63 | resource: McpResource, |
| 64 | 64 | ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig; |
| @@ -121,15 +121,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines | ||
| 121 | 121 | return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; |
| 122 | 122 | }; |
| 123 | 123 | |
| 124 | −const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => | |
| 124 | +const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null => | |
| 125 | 125 | "engine" in config ? config.engine : null; |
| 126 | 126 | |
| 127 | 127 | const normalizeHandlerConfig = ( |
| 128 | − input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | |
| 128 | + input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, | |
| 129 | 129 | ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); |
| 130 | 130 | |
| 131 | 131 | export const createMcpRequestHandler = ( |
| 132 | − input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | |
| 132 | + input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, | |
| 133 | 133 | ): McpRequestHandler => { |
| 134 | 134 | const handlerConfig = normalizeHandlerConfig(input); |
| 135 | 135 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| @@ -193,7 +193,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines | ||
| 193 | 193 | Effect.gen(function* () { |
| 194 | 194 | const resourceConfig = yield* Effect.promise(() => configForResource(resource)); |
| 195 | 195 | const clientCapabilities = yield* clientCapabilitiesFromRequest(request); |
| 196 | − const server = yield* buildMcpServerV2({ | |
| 196 | + const server = yield* buildMcpServer({ | |
| 197 | 197 | ...resourceConfig.config, |
| 198 | 198 | artifactsEnabled: readArtifactsEnabled(request), |
| 199 | 199 | appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), |
| @@ -277,7 +277,7 @@ export const createMcpRequestHandler = ( · 77 unmodified lines | ||
| 277 | 277 | const elicitationMode = readElicitationMode(request); |
| 278 | 278 | resourceConfig = await configForResource(resource); |
| 279 | 279 | created = await Effect.runPromise( |
| 280 | − buildMcpServerV2({ | |
| 280 | + buildMcpServer({ | |
| 281 | 281 | ...resourceConfig.config, |
| 282 | 282 | browserApprovalStore: approvals.store, |
| 283 | 283 | artifactsEnabled: readArtifactsEnabled(request), |
| @@ -360,13 +360,13 @@ export const createMcpRequestHandler = ( · 76 unmodified lines | ||
| 360 | 360 | // Stdio transport |
| 361 | 361 | // --------------------------------------------------------------------------- |
| 362 | 362 | |
| 363 | −export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => { | |
| 363 | +export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => { | |
| 364 | 364 | startIntegrationsRefresh(); |
| 365 | 365 | |
| 366 | 366 | const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); |
| 367 | 367 | const stdio = serveStdio(() => |
| 368 | 368 | Effect.runPromise( |
| 369 | − buildMcpServerV2({ | |
| 369 | + buildMcpServer({ | |
| 370 | 370 | ...config, |
| 371 | 371 | appsEnabled: config.restoredAppsEnabled ?? false, |
| 372 | 372 | requestStateSigningKey, |
| @@ -701,12 +701,9 @@ · 700 unmodified lines | ||
| 701 | 701 | "name": "@executor-js/host-mcp", |
| 702 | 702 | "version": "1.4.4", |
| 703 | 703 | "dependencies": { |
| 704 | − "@cfworker/json-schema": "^4.1.1", | |
| 705 | 704 | "@executor-js/execution": "workspace:*", |
| 706 | 705 | "@executor-js/sdk": "workspace:*", |
| 707 | 706 | "@modelcontextprotocol/core": "2.0.0", |
| 708 | − "@modelcontextprotocol/ext-apps": "^1.7.4", | |
| 709 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 710 | 707 | "@modelcontextprotocol/server": "2.0.0", |
| 711 | 708 | "effect": "catalog:", |
| 712 | 709 | "zod": "4.3.6", |
| @@ -714,6 +711,7 @@ · 1 unmodified line | ||
| 714 | 711 | "devDependencies": { |
| 715 | 712 | "@effect/vitest": "catalog:", |
| 716 | 713 | "@modelcontextprotocol/client": "2.0.0", |
| 714 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 717 | 715 | "@types/node": "catalog:", |
| 718 | 716 | "bun-types": "catalog:", |
| 719 | 717 | "vitest": "catalog:", |
| @@ -58,9 +58,8 @@ export { · 57 unmodified lines | ||
| 58 | 58 | } from "./server/execution-stack"; |
| 59 | 59 | export { |
| 60 | 60 | makeMcpBuildServer, |
| 61 | − makeMcpBuildServerV2, | |
| 62 | 61 | makeConsoleMcpErrorReporter, |
| 63 | − type McpBuildServerV2, | |
| 62 | + type McpBuildServer, | |
| 64 | 63 | type McpExecutionStackLayer, |
| 65 | 64 | } from "./server/mcp-build"; |
| 66 | 65 | // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author |
| @@ -148,7 +148,7 @@ export type McpProviders<RMcpAuth = never> = McpProviderBase<RMcpAuth> & · 147 unmodified lines | ||
| 148 | 148 | * Owns the legacy serving-session lifecycle (in-process Map vs DO). |
| 149 | 149 | */ |
| 150 | 150 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 151 | − /** Builds one stateless SDK v2 server for each modern request. */ | |
| 151 | + /** Builds one stateless MCP server for each modern request. */ | |
| 152 | 152 | readonly modern: Layer.Layer<McpModernServerBuilder>; |
| 153 | 153 | } |
| 154 | 154 | | { |
| @@ -3,13 +3,15 @@ import { Effect, Layer } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | McpErrorReporter, |
| 5 | 5 | type McpModernServerBuilder, |
| 6 | + type McpModernServerBuildOptions, | |
| 6 | 7 | type Principal, |
| 7 | 8 | } from "@executor-js/host-mcp"; |
| 8 | 9 | import { |
| 9 | 10 | McpEngineBuildError, |
| 10 | − type McpBuildServer, | |
| 11 | + type McpBuildServer as McpSessionBuildServer, | |
| 12 | + type McpBuildServerOptions as McpSessionBuildOptions, | |
| 11 | 13 | } from "@executor-js/host-mcp/in-memory-session-store"; |
| 12 | −import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; | |
| 14 | +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 13 | 15 | import { |
| 14 | 16 | artifactUrlFor, |
| 15 | 17 | type ArtifactSmokeRenderResult, |
| @@ -23,9 +25,8 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 7 unmodified lines | ||
| 23 | 25 | // --------------------------------------------------------------------------- |
| 24 | 26 | // Shared in-process MCP host helpers. |
| 25 | 27 | // |
| 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. | |
| 28 | +// Neutral hosts build both sessionful legacy-wire connections and stateless | |
| 29 | +// modern requests from the same assembly over a scoped execution stack. | |
| 29 | 30 | // --------------------------------------------------------------------------- |
| 30 | 31 | |
| 31 | 32 | /** The five execution-stack seams a host fully provides (no residual). */ |
| @@ -33,14 +34,35 @@ export type McpExecutionStackLayer = Layer.Layer< · 1 unmodified line | ||
| 33 | 34 | DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator |
| 34 | 35 | >; |
| 35 | 36 | |
| 37 | +type McpSessionBuildEffect = ReturnType<McpSessionBuildServer>; | |
| 38 | +type McpModernBuildEffect = ReturnType<McpModernServerBuilder["Service"]["build"]>; | |
| 39 | + | |
| 40 | +/** A single build seam accepted by both the session store and modern envelope. */ | |
| 41 | +export interface McpBuildServer { | |
| 42 | + /** Build a connection-lifetime server and retain its engine for session-owned approvals. */ | |
| 43 | + (principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect; | |
| 44 | + /** Build a stateless server for one modern request. */ | |
| 45 | + (principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect; | |
| 46 | +} | |
| 47 | + | |
| 36 | 48 | /** |
| 37 | − * Build the per-session MCP server factory over a host's execution stack: | |
| 38 | − * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only | |
| 39 | − * in the injected stack layer (libSQL vs D1, etc.). | |
| 49 | + * Build the unified MCP server factory over a host's execution stack. | |
| 50 | + * Session callers receive the server plus its approval-owning engine; modern | |
| 51 | + * request callers receive the server directly. | |
| 40 | 52 | */ |
| 41 | −export const makeMcpBuildServer = | |
| 42 | − (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer => | |
| 43 | − (principal: Principal, options) => { | |
| 53 | +export const makeMcpBuildServer = ( | |
| 54 | + executionStack: McpExecutionStackLayer, | |
| 55 | + hostOptions?: McpBuildHostOptions, | |
| 56 | +): McpBuildServer => { | |
| 57 | + function build(principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect; | |
| 58 | + function build(principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect; | |
| 59 | + function build( | |
| 60 | + principal: Principal, | |
| 61 | + options: McpSessionBuildOptions | McpModernServerBuildOptions, | |
| 62 | + ): Effect.Effect< | |
| 63 | + Effect.Success<McpSessionBuildEffect> | Effect.Success<McpModernBuildEffect>, | |
| 64 | + Effect.Error<McpSessionBuildEffect> | Effect.Error<McpModernBuildEffect> | |
| 65 | + > { | |
| 44 | 66 | const { resource, ...serverOptions } = options; |
| 45 | 67 | return Effect.gen(function* () { |
| 46 | 68 | const { engine, executor } = yield* makeExecutionStack( |
| @@ -50,20 +72,17 @@ export const makeMcpBuildServer = · 3 unmodified lines | ||
| 50 | 72 | { mcpResource: resource }, |
| 51 | 73 | ).pipe(Effect.withSpan("mcp.execution_stack.build")); |
| 52 | 74 | // Read inside the provided boundary: `webBaseUrl` is a host seam, and |
| 53 | − // hosts that can't know their public URL at boot leave it unset — in | |
| 54 | − // which case artifacts still persist but carry no deep link. | |
| 75 | + // hosts that cannot know their public URL at boot leave it unset. | |
| 55 | 76 | const hostConfig = yield* HostConfig; |
| 56 | 77 | return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; |
| 57 | 78 | }).pipe( |
| 58 | − // Pin browser-handoff URLs to the principal's org slug when present; | |
| 59 | − // absent slug leaves the service unprovided and the URL stays bare. | |
| 60 | 79 | principal.organizationSlug !== undefined |
| 61 | 80 | ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) |
| 62 | 81 | : (effect) => effect, |
| 63 | 82 | Effect.provide(executionStack), |
| 64 | 83 | Effect.mapError((cause) => new McpEngineBuildError({ cause })), |
| 65 | 84 | Effect.flatMap(({ engine, executor, webBaseUrl }) => |
| 66 | − buildMcpServerV2({ | |
| 85 | + buildMcpServer({ | |
| 67 | 86 | engine, |
| 68 | 87 | artifacts: executor.artifacts, |
| 69 | 88 | connections: executor.connections, |
| @@ -74,68 +93,20 @@ export const makeMcpBuildServer = · 4 unmodified lines | ||
| 74 | 93 | ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact } |
| 75 | 94 | : {}), |
| 76 | 95 | ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}), |
| 77 | − // Same org pinning as `RequestOrgSlug` above: self-host serves its | |
| 78 | − // console under `/<org-slug>` (`default` when unconfigured), so the | |
| 79 | − // deep link carries the principal's slug rather than relying on the | |
| 80 | − // browser's active org to canonicalize a bare path after landing. | |
| 81 | 96 | ...(webBaseUrl |
| 82 | 97 | ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } |
| 83 | 98 | : {}), |
| 84 | 99 | ...serverOptions, |
| 85 | 100 | }).pipe( |
| 86 | 101 | Effect.withSpan("mcp.server.create"), |
| 87 | − Effect.map((mcpServer) => ({ mcpServer, engine })), | |
| 102 | + Effect.map((mcpServer) => ("sessionful" in options ? { mcpServer, engine } : mcpServer)), | |
| 88 | 103 | ), |
| 89 | 104 | ), |
| 90 | 105 | ); |
| 91 | − }; | |
| 106 | + } | |
| 92 | 107 | |
| 93 | −/** Build function consumed by the neutral envelope's modern-server seam. */ | |
| 94 | −export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"]; | |
| 95 | − | |
| 96 | −/** | |
| 97 | − * Build the per-request SDK v2 server factory over the same execution stack | |
| 98 | − * and host configuration used by {@link makeMcpBuildServer}. | |
| 99 | − */ | |
| 100 | −export const makeMcpBuildServerV2 = | |
| 101 | − (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServerV2 => | |
| 102 | − (principal, options) => { | |
| 103 | − const { resource, ...requestOptions } = options; | |
| 104 | − return Effect.gen(function* () { | |
| 105 | − const { engine, executor } = yield* makeExecutionStack( | |
| 106 | − principal.accountId, | |
| 107 | − principal.organizationId, | |
| 108 | − principal.organizationName, | |
| 109 | − { mcpResource: resource }, | |
| 110 | − ).pipe(Effect.withSpan("mcp.execution_stack.build")); | |
| 111 | − const hostConfig = yield* HostConfig; | |
| 112 | − return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; | |
| 113 | − }).pipe( | |
| 114 | − principal.organizationSlug !== undefined | |
| 115 | − ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) | |
| 116 | − : (effect) => effect, | |
| 117 | − Effect.provide(executionStack), | |
| 118 | − Effect.mapError((cause) => new McpEngineBuildError({ cause })), | |
| 119 | − Effect.flatMap(({ engine, executor, webBaseUrl }) => | |
| 120 | − buildMcpServerV2({ | |
| 121 | − engine, | |
| 122 | − artifacts: executor.artifacts, | |
| 123 | − connections: executor.connections, | |
| 124 | − ...(hostOptions?.loadAppShellHtml | |
| 125 | − ? { loadAppShellHtml: hostOptions.loadAppShellHtml } | |
| 126 | − : {}), | |
| 127 | − ...(hostOptions?.smokeRenderArtifact | |
| 128 | − ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact } | |
| 129 | − : {}), | |
| 130 | − ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}), | |
| 131 | − ...(webBaseUrl | |
| 132 | − ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } | |
| 133 | − : {}), | |
| 134 | − ...requestOptions, | |
| 135 | − }).pipe(Effect.withSpan("mcp.server.create")), | |
| 136 | − ), | |
| 137 | − ); | |
| 138 | − }; | |
| 108 | + return build; | |
| 109 | +}; | |
| 139 | 110 | |
| 140 | 111 | /** Per-host (not per-session) MCP wiring. Kept separate from |
| 141 | 112 | * `McpBuildServerOptions`, which the session store fills in per request. */ |
| @@ -5,7 +5,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 7 | 7 | import { defaultMcpResource } from "@executor-js/host-mcp"; |
| 8 | −import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; | |
| 8 | +import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server"; | |
| 9 | 9 | |
| 10 | 10 | import { withVerifiedIdentityHeaders } from "./do-headers"; |
| 11 | 11 | import { |
| @@ -208,7 +208,7 @@ class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, { readonly en · 196 unmodified lines | ||
| 208 | 208 | |
| 209 | 209 | protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect<BuiltMcpServer> { |
| 210 | 210 | const elicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 211 | − return buildMcpServerV2({ | |
| 211 | + return buildMcpServer({ | |
| 212 | 212 | engine: this.sessionEngine, |
| 213 | 213 | appsEnabled: false, |
| 214 | 214 | restoredAppsEnabled: sessionMeta.appsEnabled, |
| @@ -256,7 +256,7 @@ const makeClientHarness = (state = new MemoryDurableObjectState()) => { · 41 unmodified lines | ||
| 256 | 256 | }; |
| 257 | 257 | }; |
| 258 | 258 | |
| 259 | −describe("McpAgentSessionDOBase SDK v2 session serving", () => { | |
| 259 | +describe("McpAgentSessionDOBase session serving", () => { | |
| 260 | 260 | it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => { |
| 261 | 261 | const harness = makeClientHarness(); |
| 262 | 262 | await harness.client.connect(harness.transport); |
| @@ -392,7 +392,7 @@ describe("McpAgentSessionDOBase SDK v2 session serving", () => { · 129 unmodified lines | ||
| 392 | 392 | expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`); |
| 393 | 393 | }); |
| 394 | 394 | |
| 395 | − it("persists v2 metadata and rejects a different principal", async () => { | |
| 395 | + it("persists session metadata and rejects a different principal", async () => { | |
| 396 | 396 | const harness = makeClientHarness(); |
| 397 | 397 | await harness.client.connect(harness.transport); |
| 398 | 398 | |
| @@ -419,7 +419,7 @@ describe("McpAgentSessionDOBase SDK v2 session serving", () => { · 20 unmodified lines | ||
| 419 | 419 | } |
| 420 | 420 | }); |
| 421 | 421 | |
| 422 | − it("returns a clean 404 for storage created by the pre-v2 Agent stack", async () => { | |
| 422 | + it("returns a clean 404 for storage created by the retired Agent stack", async () => { | |
| 423 | 423 | const state = new MemoryDurableObjectState(); |
| 424 | 424 | await state.storage.put("session-meta", { |
| 425 | 425 | organizationId: ORGANIZATION_ID, |
| @@ -22,8 +22,12 @@ import { · 21 unmodified lines | ||
| 22 | 22 | type ResumeResponse, |
| 23 | 23 | } from "@executor-js/execution"; |
| 24 | 24 | import { |
| 25 | + appsEnabledForClientCapabilities, | |
| 26 | + clientCapabilitiesFromRequestBody, | |
| 25 | 27 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 26 | 28 | formatMcpExecutionOutcome, |
| 29 | + mcpRequestStatePrincipal, | |
| 30 | + requestBodyFromRequest, | |
| 27 | 31 | type PausedExecutionHooks, |
| 28 | 32 | type ResumeFallbackOutcome, |
| 29 | 33 | } from "@executor-js/host-mcp/tool-server"; |
| @@ -33,13 +37,6 @@ import { · 3 unmodified lines | ||
| 33 | 37 | mcpResourceKey, |
| 34 | 38 | type McpResource, |
| 35 | 39 | } from "@executor-js/host-mcp"; |
| 36 | −import { | |
| 37 | − appsEnabledForClientCapabilities, | |
| 38 | − clientCapabilitiesFromRequestBody, | |
| 39 | − mcpRequestStatePrincipal, | |
| 40 | − requestBodyFromRequest, | |
| 41 | −} from "@executor-js/host-mcp/tool-server-v2"; | |
| 42 | − | |
| 43 | 40 | import { |
| 44 | 41 | readArtifactsEnabled, |
| 45 | 42 | readElicitationMode, |
| @@ -149,7 +146,7 @@ export interface SessionMeta { · 103 unmodified lines | ||
| 149 | 146 | * unknown, which behaves as disabled until the next `initialize`. |
| 150 | 147 | */ |
| 151 | 148 | readonly appsEnabled?: boolean; |
| 152 | − /** Creation time of this v2 session, retained across isolate eviction. */ | |
| 149 | + /** Creation time of this session, retained across isolate eviction. */ | |
| 153 | 150 | readonly createdAtMs?: number; |
| 154 | 151 | } |
| 155 | 152 | |
| @@ -160,14 +157,14 @@ export interface BuiltMcpServer { · 4 unmodified lines | ||
| 160 | 157 | readonly modernRuntime?: BuiltModernMcpRuntime; |
| 161 | 158 | } |
| 162 | 159 | |
| 163 | −/** Request-specific inputs added to a DO-local SDK v2 server. */ | |
| 160 | +/** Request-specific inputs added to a DO-local MCP server. */ | |
| 164 | 161 | export interface ModernMcpServerRequestOptions { |
| 165 | 162 | readonly appsEnabled: boolean; |
| 166 | 163 | readonly requestStateSigningKey: Uint8Array | string; |
| 167 | 164 | readonly requestStatePrincipal: string; |
| 168 | 165 | } |
| 169 | 166 | |
| 170 | −/** Long-lived DO execution runtime shared by per-request SDK v2 servers. */ | |
| 167 | +/** Long-lived DO execution runtime shared by per-request MCP servers. */ | |
| 171 | 168 | export interface BuiltModernMcpRuntime { |
| 172 | 169 | readonly engine: ExecutionEngine<Cause.YieldableError>; |
| 173 | 170 | readonly buildServer: ( |
| @@ -186,8 +183,8 @@ type ModernRuntimeAccess = · 12 unmodified lines | ||
| 186 | 183 | |
| 187 | 184 | class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} |
| 188 | 185 | |
| 189 | −const LEGACY_V2_SESSION_META_KEY = "executor:mcp:v2:session-meta"; | |
| 190 | −const LEGACY_V2_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms"; | |
| 186 | +const LEGACY_AGENT_SESSION_META_KEY = "executor:mcp:v2:session-meta"; | |
| 187 | +const LEGACY_AGENT_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms"; | |
| 191 | 188 | const MODERN_SESSION_META_KEY = "session-meta"; |
| 192 | 189 | const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms"; |
| 193 | 190 | const MODERN_SESSION_KEY = "modern-session"; |
| @@ -313,7 +310,7 @@ export abstract class McpAgentSessionDOBase< · 119 unmodified lines | ||
| 313 | 310 | dbHandle: TDbHandle, |
| 314 | 311 | ): Effect.Effect<BuiltMcpServer>; |
| 315 | 312 | |
| 316 | − /** Build the engine and per-request SDK v2 server factory for a modern-only DO. */ | |
| 313 | + /** Build the engine and per-request MCP server factory for a modern-only DO. */ | |
| 317 | 314 | protected buildModernMcpRuntime( |
| 318 | 315 | _sessionMeta: SessionMeta, |
| 319 | 316 | _dbHandle: TDbHandle, |
| @@ -448,7 +445,7 @@ export abstract class McpAgentSessionDOBase< · 128 unmodified lines | ||
| 448 | 445 | return Effect.promise(async () => { |
| 449 | 446 | if (this.sessionMeta) return this.sessionMeta; |
| 450 | 447 | |
| 451 | − const legacy = await this.ctx.storage.get<SessionMeta>(LEGACY_V2_SESSION_META_KEY); | |
| 448 | + const legacy = await this.ctx.storage.get<SessionMeta>(LEGACY_AGENT_SESSION_META_KEY); | |
| 452 | 449 | if (legacy) { |
| 453 | 450 | this.runtimeKind = "legacy"; |
| 454 | 451 | this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource }; |
| @@ -475,14 +472,14 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines | ||
| 475 | 472 | private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> { |
| 476 | 473 | this.sessionMeta = sessionMeta; |
| 477 | 474 | const key = |
| 478 | − this.runtimeKind === "modern" ? MODERN_SESSION_META_KEY : LEGACY_V2_SESSION_META_KEY; | |
| 475 | + this.runtimeKind === "modern" ? MODERN_SESSION_META_KEY : LEGACY_AGENT_SESSION_META_KEY; | |
| 479 | 476 | await this.ctx.storage.put(key, sessionMeta); |
| 480 | 477 | } |
| 481 | 478 | |
| 482 | 479 | /** |
| 483 | 480 | * Persist the MCP-Apps support negotiated at `initialize`, so a later cold |
| 484 | 481 | * restore can rebuild the server with it. Subclasses hand this to |
| 485 | − * `createExecutorMcpServer` as `onAppsEnabledChange`. | |
| 482 | + * `buildMcpServer` as `onAppsEnabledChange`. | |
| 486 | 483 | * |
| 487 | 484 | * A no-op before meta exists: `initialize` always follows `init`, so there is |
| 488 | 485 | * nothing to merge into and nothing worth failing the session over. |
| @@ -504,7 +501,7 @@ export abstract class McpAgentSessionDOBase< · 15 unmodified lines | ||
| 504 | 501 | private async markActivity(now = Date.now()): Promise<void> { |
| 505 | 502 | this.lastActivityMs = now; |
| 506 | 503 | const key = |
| 507 | − this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY; | |
| 504 | + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY; | |
| 508 | 505 | await Promise.all([ |
| 509 | 506 | this.ctx.storage.put(key, now), |
| 510 | 507 | this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()), |
| @@ -514,7 +511,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines | ||
| 514 | 511 | private async loadLastActivity(): Promise<number> { |
| 515 | 512 | if (this.lastActivityMs > 0) return this.lastActivityMs; |
| 516 | 513 | const key = |
| 517 | − this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY; | |
| 514 | + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY; | |
| 518 | 515 | const stored = await this.ctx.storage.get<number>(key); |
| 519 | 516 | this.lastActivityMs = stored ?? 0; |
| 520 | 517 | return this.lastActivityMs; |
| @@ -546,7 +543,7 @@ export abstract class McpAgentSessionDOBase< · 25 unmodified lines | ||
| 546 | 543 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), |
| 547 | 544 | Effect.ignore( |
| 548 | 545 | Effect.tryPromise(() => |
| 549 | − this.ctx.storage.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]), | |
| 546 | + this.ctx.storage.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]), | |
| 550 | 547 | ), |
| 551 | 548 | ), |
| 552 | 549 | ]), |
| @@ -571,7 +568,7 @@ export abstract class McpAgentSessionDOBase< · 18 unmodified lines | ||
| 571 | 568 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), |
| 572 | 569 | Effect.ignore( |
| 573 | 570 | Effect.tryPromise(() => |
| 574 | − this.ctx.storage.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]), | |
| 571 | + this.ctx.storage.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]), | |
| 575 | 572 | ), |
| 576 | 573 | ), |
| 577 | 574 | ]), |
| @@ -891,7 +888,7 @@ export abstract class McpAgentSessionDOBase< · 313 unmodified lines | ||
| 891 | 888 | |
| 892 | 889 | private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void { |
| 893 | 890 | transport.sessionId = this.sessionId; |
| 894 | − // SAFETY: SDK v2 exposes `sessionId` but not a public cold-restore setter. | |
| 891 | + // SAFETY: the SDK exposes `sessionId` but not a public cold-restore setter. | |
| 895 | 892 | // The installed transport's only additional session-validation bit is the |
| 896 | 893 | // runtime `_initialized` boolean. Restoring just those transport fields |
| 897 | 894 | // intentionally leaves McpServer client capabilities absent, so the |
| @@ -993,7 +990,7 @@ export abstract class McpAgentSessionDOBase< · 95 unmodified lines | ||
| 993 | 990 | transport: WebStandardStreamableHTTPServerTransport, |
| 994 | 991 | requestId: RequestId, |
| 995 | 992 | ): string | null { |
| 996 | − // SAFETY: SDK v2 currently has no public hook exposing the per-POST stream | |
| 993 | + // SAFETY: the SDK currently has no public hook exposing the per-POST stream | |
| 997 | 994 | // ID. The installed transport stores the exact request-id → stream-id map |
| 998 | 995 | // used by replay. Reading it lets the legacy compatibility prime share the |
| 999 | 996 | // same replay stream as the eventual result without changing SDK code. |
| @@ -9,7 +9,7 @@ import type { · 8 unmodified lines | ||
| 9 | 9 | } from "@executor-js/execution"; |
| 10 | 10 | import { defaultMcpResource } from "@executor-js/host-mcp"; |
| 11 | 11 | import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server"; |
| 12 | −import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; | |
| 12 | +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 13 | 13 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; |
| 14 | 14 | |
| 15 | 15 | import { |
| @@ -234,7 +234,7 @@ const makeHarness = () => { · 218 unmodified lines | ||
| 234 | 234 | Effect.succeed({ |
| 235 | 235 | engine, |
| 236 | 236 | buildServer: (options: ModernMcpServerRequestOptions) => |
| 237 | − buildMcpServerV2({ | |
| 237 | + buildMcpServer({ | |
| 238 | 238 | engine, |
| 239 | 239 | elicitationMode: { mode: "native" }, |
| 240 | 240 | pausedExecutionHooks: session.modernPausedExecutionHooks, |
| @@ -47,7 +47,7 @@ const logStoreWarning = (event: string, fields: Record<string, unknown>): void = · 46 unmodified lines | ||
| 47 | 47 | }; |
| 48 | 48 | |
| 49 | 49 | /** |
| 50 | − * SDK v2 replay storage backed by one MCP session Durable Object's KV store. | |
| 50 | + * MCP replay storage backed by one session Durable Object's KV store. | |
| 51 | 51 | * |
| 52 | 52 | * Writes are deliberately best-effort: storage limits or outages never escape |
| 53 | 53 | * into the transport's send path. Every call still returns a monotonic event |
| @@ -8,7 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type McpModernServerBuilder, |
| 9 | 9 | type Principal, |
| 10 | 10 | } from "@executor-js/host-mcp"; |
| 11 | −import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; | |
| 11 | +import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server"; | |
| 12 | 12 | |
| 13 | 13 | import type { McpSessionProps } from "./agent-session-durable-object"; |
| 14 | 14 | import { |
| @@ -149,7 +149,7 @@ const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service · 134 unmodified lines | ||
| 149 | 149 | build: (_principal, options) => { |
| 150 | 150 | builds.count += 1; |
| 151 | 151 | const { resource: _resource, ...requestOptions } = options; |
| 152 | − return buildMcpServerV2({ | |
| 152 | + return buildMcpServer({ | |
| 153 | 153 | engine, |
| 154 | 154 | elicitationMode: { mode: "native" }, |
| 155 | 155 | ...requestOptions, |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | clientCapabilitiesFromRequestBody, |
| 19 | 19 | mcpRequestStatePrincipal, |
| 20 | 20 | verifyNativeRequestState, |
| 21 | −} from "@executor-js/host-mcp/tool-server-v2"; | |
| 21 | +} from "@executor-js/host-mcp/tool-server"; | |
| 22 | 22 | |
| 23 | 23 | import type { McpSessionProps } from "./agent-session-durable-object"; |
| 24 | 24 | import type { McpExecutionOwnerDirectory } from "./execution-owner-directory"; |
| @@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 3 unmodified lines | ||
| 4 | 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| 5 | 5 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; |
| 6 | 6 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; |
| 7 | −import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 7 | +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 8 | 8 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; |
| 9 | 9 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 10 | 10 | |
| @@ -40,8 +40,12 @@ describe("MCP-Apps shell resource", () => { · 29 unmodified lines | ||
| 40 | 40 | const mcpServer = await Effect.runPromise( |
| 41 | 41 | // Artifacts are opt-in per connection; the shell resource only exists on |
| 42 | 42 | // a session that asked for them. |
| 43 | − createExecutorMcpServer({ | |
| 43 | + buildMcpServer({ | |
| 44 | 44 | engine: stubEngine, |
| 45 | + appsEnabled: false, | |
| 46 | + requestStateSigningKey: new Uint8Array(32).fill(17), | |
| 47 | + requestStatePrincipal: "shell-resource-test-principal", | |
| 48 | + sessionful: true, | |
| 45 | 49 | loadAppShellHtml: loadMcpAppsShellHtml, |
| 46 | 50 | artifactsEnabled: true, |
| 47 | 51 | }), |
| @@ -38,7 +38,7 @@ import { chromium, type Browser, type Frame, type Page } from "playwright-core"; · 37 unmodified lines | ||
| 38 | 38 | import { createServer as createViteServer } from "vite"; |
| 39 | 39 | import type * as Cause from "effect/Cause"; |
| 40 | 40 | |
| 41 | −import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 41 | +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 42 | 42 | |
| 43 | 43 | import { loadMcpAppsShellHtml } from "../shell-html"; |
| 44 | 44 | |
| @@ -1241,8 +1241,12 @@ const startMcpHarnessForEngine = async <E extends Cause.YieldableError>( · 1196 unmodified lines | ||
| 1241 | 1241 | engine: ExecutionEngine<E>, |
| 1242 | 1242 | ): Promise<McpHarness> => { |
| 1243 | 1243 | const mcpServer = await Effect.runPromise( |
| 1244 | − createExecutorMcpServer({ | |
| 1244 | + buildMcpServer({ | |
| 1245 | 1245 | engine, |
| 1246 | + appsEnabled: false, | |
| 1247 | + requestStateSigningKey: new Uint8Array(32).fill(19), | |
| 1248 | + requestStatePrincipal: "mcp-app-browser-test-principal", | |
| 1249 | + sessionful: true, | |
| 1246 | 1250 | loadAppShellHtml: loadMcpAppsShellHtml, |
| 1247 | 1251 | artifacts: makeInMemoryArtifacts(), |
| 1248 | 1252 | // Artifacts are opt-in per connection; this harness drives the artifact |
| @@ -12,10 +12,6 @@ · 11 unmodified lines | ||
| 12 | 12 | "types": "./src/tool-server.ts", |
| 13 | 13 | "default": "./src/tool-server.ts" |
| 14 | 14 | }, |
| 15 | − "./tool-server-v2": { | |
| 16 | − "types": "./src/tool-server-v2.ts", | |
| 17 | − "default": "./src/tool-server-v2.ts" | |
| 18 | − }, | |
| 19 | 15 | "./mcp-apps": { |
| 20 | 16 | "types": "./src/mcp-apps.ts", |
| 21 | 17 | "default": "./src/mcp-apps.ts" |
| @@ -52,12 +48,9 @@ · 30 unmodified lines | ||
| 52 | 48 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" |
| 53 | 49 | }, |
| 54 | 50 | "dependencies": { |
| 55 | − "@cfworker/json-schema": "^4.1.1", | |
| 56 | 51 | "@executor-js/execution": "workspace:*", |
| 57 | 52 | "@executor-js/sdk": "workspace:*", |
| 58 | 53 | "@modelcontextprotocol/core": "2.0.0", |
| 59 | − "@modelcontextprotocol/ext-apps": "^1.7.4", | |
| 60 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 61 | 54 | "@modelcontextprotocol/server": "2.0.0", |
| 62 | 55 | "effect": "catalog:", |
| 63 | 56 | "zod": "4.3.6" |
| @@ -65,6 +58,7 @@ · 1 unmodified line | ||
| 65 | 58 | "devDependencies": { |
| 66 | 59 | "@effect/vitest": "catalog:", |
| 67 | 60 | "@modelcontextprotocol/client": "2.0.0", |
| 61 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 68 | 62 | "@types/node": "catalog:", |
| 69 | 63 | "bun-types": "catalog:", |
| 70 | 64 | "vitest": "catalog:" |
| @@ -1,9 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it, vi } from "@effect/vitest"; |
| 2 | 2 | import { Data, Effect } from "effect"; |
| 3 | −import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 4 | −import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | |
| 5 | −import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | |
| 6 | −import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; | |
| 3 | +import { Client } from "@modelcontextprotocol/client"; | |
| 4 | +import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; | |
| 7 | 5 | import type * as Cause from "effect/Cause"; |
| 8 | 6 | |
| 9 | 7 | import { |
| @@ -18,7 +16,8 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 8 unmodified lines | ||
| 18 | 16 | import type { BindableConnection } from "./artifact-bindings"; |
| 19 | 17 | import { readArtifactsEnabled } from "./browser-approval"; |
| 20 | 18 | import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact"; |
| 21 | −import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; | |
| 19 | +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; | |
| 20 | +import { buildMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; | |
| 22 | 21 | |
| 23 | 22 | /** The caller's connection inventory, as `create-artifact` sees it when it |
| 24 | 23 | * binds an artifact's integration roles. */ |
| @@ -130,6 +129,14 @@ const makeArtifactStore = () => { · 105 unmodified lines | ||
| 130 | 129 | }; |
| 131 | 130 | }; |
| 132 | 131 | |
| 132 | +const TEST_REQUEST_STATE_KEY = new Uint8Array(32).fill(11); | |
| 133 | +const SESSION_SERVER_OPTIONS = { | |
| 134 | + appsEnabled: false, | |
| 135 | + requestStateSigningKey: TEST_REQUEST_STATE_KEY, | |
| 136 | + requestStatePrincipal: "artifact-test-principal", | |
| 137 | + sessionful: true, | |
| 138 | +} as const; | |
| 139 | + | |
| 133 | 140 | const withClient = async <E extends Cause.YieldableError>( |
| 134 | 141 | engine: ExecutionEngine<E>, |
| 135 | 142 | capabilities: ClientCapabilities, |
| @@ -137,8 +144,9 @@ const withClient = async <E extends Cause.YieldableError>( · 1 unmodified line | ||
| 137 | 144 | config?: Partial<ExecutorMcpServerConfig<E>>, |
| 138 | 145 | ) => { |
| 139 | 146 | const mcpServer = await Effect.runPromise( |
| 140 | − createExecutorMcpServer({ | |
| 147 | + buildMcpServer({ | |
| 141 | 148 | engine, |
| 149 | + ...SESSION_SERVER_OPTIONS, | |
| 142 | 150 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), |
| 143 | 151 | // Artifacts are on by default and nearly every test in this file |
| 144 | 152 | // exercises the artifact surface; spelled out here anyway so the cases |
| @@ -164,13 +172,10 @@ const withClient = async <E extends Cause.YieldableError>( · 19 unmodified lines | ||
| 164 | 172 | |
| 165 | 173 | const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>"; |
| 166 | 174 | |
| 167 | −// What a client that renders MCP Apps advertises at `initialize`. The SDK's | |
| 168 | −// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which | |
| 169 | −// is exactly why ext-apps ships `getUiCapability` to read it. | |
| 170 | −// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field | |
| 175 | +// What a client that renders MCP Apps advertises at `initialize`. | |
| 171 | 176 | const APPS_CAPS = { |
| 172 | 177 | extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, |
| 173 | −} as unknown as ClientCapabilities; | |
| 178 | +} satisfies ClientCapabilities; | |
| 174 | 179 | |
| 175 | 180 | const NO_APPS_CAPS: ClientCapabilities = {}; |
| 176 | 181 | |
| @@ -244,8 +249,9 @@ describe("MCP host — artifact tool visibility", () => { · 67 unmodified lines | ||
| 244 | 249 | it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => { |
| 245 | 250 | const store = makeArtifactStore(); |
| 246 | 251 | const mcpServer = await Effect.runPromise( |
| 247 | − createExecutorMcpServer({ | |
| 252 | + buildMcpServer({ | |
| 248 | 253 | engine: makeStubEngine({}), |
| 254 | + ...SESSION_SERVER_OPTIONS, | |
| 249 | 255 | artifacts: store.port, |
| 250 | 256 | artifactsEnabled: true, |
| 251 | 257 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), |
| @@ -307,8 +313,9 @@ describe("MCP host — artifact tool visibility", () => { · 55 unmodified lines | ||
| 307 | 313 | it("renders inline on a restore that replays initialize without the initialized notification", async () => { |
| 308 | 314 | const store = makeArtifactStore(); |
| 309 | 315 | const mcpServer = await Effect.runPromise( |
| 310 | − createExecutorMcpServer({ | |
| 316 | + buildMcpServer({ | |
| 311 | 317 | engine: makeStubEngine({}), |
| 318 | + ...SESSION_SERVER_OPTIONS, | |
| 312 | 319 | artifacts: store.port, |
| 313 | 320 | artifactsEnabled: true, |
| 314 | 321 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), |
| @@ -460,8 +467,9 @@ describe("MCP host — artifact tool visibility", () => { · 145 unmodified lines | ||
| 460 | 467 | it("registers no ui tools at all when no shell loader is configured", async () => { |
| 461 | 468 | const store = makeArtifactStore(); |
| 462 | 469 | const mcpServer = await Effect.runPromise( |
| 463 | − createExecutorMcpServer({ | |
| 470 | + buildMcpServer({ | |
| 464 | 471 | engine: makeStubEngine({}), |
| 472 | + ...SESSION_SERVER_OPTIONS, | |
| 465 | 473 | artifacts: store.port, |
| 466 | 474 | // Opted in, so the only thing withholding the surface is the missing |
| 467 | 475 | // shell loader — otherwise this would pass on the connection default. |
| @@ -1303,7 +1311,7 @@ describe("MCP host — artifact retrieval", () => { · 835 unmodified lines | ||
| 1303 | 1311 | |
| 1304 | 1312 | await client.callTool({ |
| 1305 | 1313 | name: "create-artifact", |
| 1306 | − arguments: { code: COUNTER_CODE, title: "Dashboard v2", artifactId: "art_1" }, | |
| 1314 | + arguments: { code: COUNTER_CODE, title: "Revised dashboard", artifactId: "art_1" }, | |
| 1307 | 1315 | }); |
| 1308 | 1316 | expect(usage).toEqual(["created", "updated"]); |
| 1309 | 1317 | |
| @@ -31,7 +31,7 @@ import { · 30 unmodified lines | ||
| 31 | 31 | } from "./index"; |
| 32 | 32 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 33 | 33 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; |
| 34 | −import { buildMcpServerV2 } from "./tool-server-v2"; | |
| 34 | +import { buildMcpServer } from "./tool-server"; | |
| 35 | 35 | |
| 36 | 36 | const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; |
| 37 | 37 | |
| @@ -60,7 +60,7 @@ const testEngine: ExecutionEngine = { · 22 unmodified lines | ||
| 60 | 60 | const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({ |
| 61 | 61 | build: (_principal, options) => { |
| 62 | 62 | const { resource: _resource, ...requestOptions } = options; |
| 63 | − return buildMcpServerV2({ engine: testEngine, ...requestOptions }); | |
| 63 | + return buildMcpServer({ engine: testEngine, ...requestOptions }); | |
| 64 | 64 | }, |
| 65 | 65 | }); |
| 66 | 66 | |
| @@ -175,7 +175,7 @@ describe("McpServingRoutes envelope", () => { · 108 unmodified lines | ||
| 175 | 175 | build: (_principal, options) => { |
| 176 | 176 | const { resource: _resource, ...requestOptions } = options; |
| 177 | 177 | return Ref.set(appsEnabled, options.appsEnabled).pipe( |
| 178 | − Effect.flatMap(() => buildMcpServerV2({ engine: testEngine, ...requestOptions })), | |
| 178 | + Effect.flatMap(() => buildMcpServer({ engine: testEngine, ...requestOptions })), | |
| 179 | 179 | ); |
| 180 | 180 | }, |
| 181 | 181 | }); |
| @@ -24,7 +24,7 @@ import { · 23 unmodified lines | ||
| 24 | 24 | clientCapabilitiesFromRequest, |
| 25 | 25 | mcpRequestStatePrincipal, |
| 26 | 26 | requestBodyFromRequest, |
| 27 | −} from "./tool-server-v2"; | |
| 27 | +} from "./tool-server"; | |
| 28 | 28 | |
| 29 | 29 | // --------------------------------------------------------------------------- |
| 30 | 30 | // Provider-neutral MCP serving envelope. |
| @@ -14,7 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | } from "./in-memory-session-store"; |
| 15 | 15 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; |
| 16 | 16 | import { defaultMcpResource, type Principal } from "./seams"; |
| 17 | −import { buildMcpServerV2 } from "./tool-server-v2"; | |
| 17 | +import { buildMcpServer } from "./tool-server"; | |
| 18 | 18 | |
| 19 | 19 | const TEST_PRINCIPAL: Principal = { |
| 20 | 20 | accountId: "acct_test", |
| @@ -71,7 +71,7 @@ const makeElicitingEngine = (): { · 50 unmodified lines | ||
| 71 | 71 | }; |
| 72 | 72 | |
| 73 | 73 | describe("in-memory MCP session store", () => { |
| 74 | − it("preserves native elicitation mode and supplies the v2 session inputs", async () => { | |
| 74 | + it("preserves native elicitation mode and supplies the session inputs", async () => { | |
| 75 | 75 | let buildOptions: McpBuildServerOptions | undefined; |
| 76 | 76 | const sessions = makeInMemoryMcpSessionStore((_principal, options) => { |
| 77 | 77 | buildOptions = options; |
| @@ -112,10 +112,10 @@ describe("in-memory MCP session store", () => { · 34 unmodified lines | ||
| 112 | 112 | expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array); |
| 113 | 113 | }); |
| 114 | 114 | |
| 115 | − it("serves a legacy client through v2 with live Apps capabilities, elicitation, and reuse", async () => { | |
| 115 | + it("serves a legacy client with live Apps capabilities, elicitation, and reuse", async () => { | |
| 116 | 116 | const { engine, resumedWith } = makeElicitingEngine(); |
| 117 | 117 | const sessions = makeInMemoryMcpSessionStore((_principal, options) => |
| 118 | − buildMcpServerV2({ | |
| 118 | + buildMcpServer({ | |
| 119 | 119 | engine, |
| 120 | 120 | ...options, |
| 121 | 121 | loadAppShellHtml: async () => "<html></html>", |
| @@ -28,7 +28,7 @@ import { · 27 unmodified lines | ||
| 28 | 28 | type Principal, |
| 29 | 29 | type McpResource, |
| 30 | 30 | } from "./seams"; |
| 31 | −import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server-v2"; | |
| 31 | +import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server"; | |
| 32 | 32 | |
| 33 | 33 | // --------------------------------------------------------------------------- |
| 34 | 34 | // In-process McpSessionStore — the single-node serving store, shared by every |
| @@ -77,9 +77,9 @@ export interface McpBuildServerOptions { · 42 unmodified lines | ||
| 77 | 77 | * with `?artifacts=false`; opted out, the built server registers none of |
| 78 | 78 | * the artifact tools, resource, or skills. */ |
| 79 | 79 | readonly artifactsEnabled?: boolean; |
| 80 | − /** The sessionful v2 assembly starts disabled and replaces this from initialize. */ | |
| 80 | + /** The sessionful assembly starts disabled and replaces this from initialize. */ | |
| 81 | 81 | readonly appsEnabled: false; |
| 82 | − /** Process-lifetime HMAC key for the v2 legacy-shim continuation state. */ | |
| 82 | + /** Process-lifetime HMAC key for legacy-shim continuation state. */ | |
| 83 | 83 | readonly requestStateSigningKey: Uint8Array; |
| 84 | 84 | /** Stable authenticated owner bound into continuation state. */ |
| 85 | 85 | readonly requestStatePrincipal: string; |
| @@ -280,7 +280,7 @@ export const makeInMemoryMcpSessionStore = ( · 194 unmodified lines | ||
| 280 | 280 | Effect.gen(function* () { |
| 281 | 281 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| 282 | 282 | sessionIdGenerator: () => crypto.randomUUID(), |
| 283 | − // Native mode needs an open SSE response for the v2 legacy shim's | |
| 283 | + // Native mode needs an open SSE response for the legacy shim's | |
| 284 | 284 | // server→client elicitation request. Other modes preserve the |
| 285 | 285 | // store's existing single-JSON response behavior. |
| 286 | 286 | enableJsonResponse: readElicitationMode(request) !== "native", |
| @@ -7,7 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | // |
| 8 | 8 | // The executor tool assemblies (execute/resume tools, elicitation and browser |
| 9 | 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 | |
| 10 | +// live behind the `tool-server` subpath so this serving | |
| 11 | 11 | // surface stays small and dependency-light. |
| 12 | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | |
| @@ -35,7 +35,7 @@ const withClient = async ( · 34 unmodified lines | ||
| 35 | 35 | } |
| 36 | 36 | }; |
| 37 | 37 | |
| 38 | −describe("vendored MCP Apps v2 helpers", () => { | |
| 38 | +describe("vendored MCP Apps helpers", () => { | |
| 39 | 39 | it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => { |
| 40 | 40 | await withClient( |
| 41 | 41 | (server) => { |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | − * Temporary MCP Apps server helpers for the v2 MCP SDK. | |
| 2 | + * Temporary MCP Apps server helpers for the current MCP SDK. | |
| 3 | 3 | * |
| 4 | 4 | * This is a wire-compatible local copy of the helpers currently published by |
| 5 | − * `@modelcontextprotocol/ext-apps/server`. The upstream package still targets | |
| 6 | − * the v1 SDK; remove this module once | |
| 5 | + * the upstream ext-apps server package. Remove this module once | |
| 7 | 6 | * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved. |
| 8 | 7 | */ |
| 9 | 8 | import type { |
| @@ -19,7 +19,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; · 18 unmodified lines | ||
| 19 | 19 | // 2. McpSessionStore — owns the serving session lifecycle: create + forward + |
| 20 | 20 | // ownership, end to end, via a single `dispatch`. The store builds/forwards |
| 21 | 21 | // the transport and returns the transport `Response`. |
| 22 | −// 3. McpModernServerBuilder — builds one stateless SDK v2 server for each | |
| 22 | +// 3. McpModernServerBuilder — builds one stateless MCP server for each | |
| 23 | 23 | // authenticated modern request. The envelope owns handler/bus lifetime. |
| 24 | 24 | // |
| 25 | 25 | // There is deliberately NO envelope-level engine seam. Both server builders |
| @@ -273,7 +273,7 @@ export class McpSessionStore extends Context.Service< · 247 unmodified lines | ||
| 273 | 273 | >()("@executor-js/host-mcp/McpSessionStore") {} |
| 274 | 274 | |
| 275 | 275 | // =========================================================================== |
| 276 | −// SEAM 3 — McpModernServerBuilder: one stateless SDK v2 server per request. | |
| 276 | +// SEAM 3 — McpModernServerBuilder: one stateless MCP server per request. | |
| 277 | 277 | // =========================================================================== |
| 278 | 278 | |
| 279 | 279 | /** Request-scoped inputs the envelope adds to a host's modern server config. */ |
| @@ -289,7 +289,7 @@ export interface McpModernServerBuildOptions { · 9 unmodified lines | ||
| 289 | 289 | } |
| 290 | 290 | |
| 291 | 291 | /** |
| 292 | − * Build one stateless SDK v2 server for an authenticated modern request. | |
| 292 | + * Build one stateless MCP server for an authenticated modern request. | |
| 293 | 293 | * |
| 294 | 294 | * The envelope owns the cached `createMcpHandler` and its subscriptions bus; |
| 295 | 295 | * providers own execution-stack construction and tool configuration here. |
| @@ -95,7 +95,7 @@ describe("MCP stdio integration", () => { · 94 unmodified lines | ||
| 95 | 95 | ); |
| 96 | 96 | |
| 97 | 97 | it.effect( |
| 98 | − "serves a modern-pinned client over the same v2 stdio entry", | |
| 98 | + "serves a modern-pinned client over the same stdio entry", | |
| 99 | 99 | () => |
| 100 | 100 | Effect.gen(function* () { |
| 101 | 101 | const transport = new ModernStdioClientTransport(stdioServer); |
| @@ -218,7 +218,7 @@ export type McpConnectionsPort = { · 217 unmodified lines | ||
| 218 | 218 | readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>; |
| 219 | 219 | }; |
| 220 | 220 | |
| 221 | −export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> = | |
| 221 | +export type ExecutorMcpToolConfig<E extends Cause.YieldableError = Cause.YieldableError> = | |
| 222 | 222 | | (ExecutionEngineConfig<E> & SharedMcpServerConfig) |
| 223 | 223 | | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig) |
| 224 | 224 | | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true }) |
| @@ -260,19 +260,19 @@ export type ResumeFallbackOutcome = · 35 unmodified lines | ||
| 260 | 260 | readonly status: "execution_not_found"; |
| 261 | 261 | }; |
| 262 | 262 | |
| 263 | −/** Request identity normalized across the v1 and v2 SDK callback contexts. */ | |
| 263 | +/** Request identity normalized from an MCP SDK callback context. */ | |
| 264 | 264 | export type McpRequestJoinKeys = { |
| 265 | 265 | readonly requestId: string | number; |
| 266 | 266 | readonly sessionId?: string | undefined; |
| 267 | 267 | }; |
| 268 | 268 | |
| 269 | −/** 2026-07-28 input-required result returned only by the v2 assembly. */ | |
| 269 | +/** 2026-07-28 input-required result returned by the MCP assembly. */ | |
| 270 | 270 | export type McpInputRequiredResult = InputRequiredResult; |
| 271 | 271 | |
| 272 | −/** Result shape shared by both assemblies; v1 never produces the second arm. */ | |
| 272 | +/** Result shape produced by Executor MCP handlers. */ | |
| 273 | 273 | export type McpHandlerResult = McpToolResult | McpInputRequiredResult; |
| 274 | 274 | |
| 275 | −/** Enable/disable controls returned by both MCP SDKs for registered tools. */ | |
| 275 | +/** Enable/disable controls returned by the MCP SDK for registered tools. */ | |
| 276 | 276 | export type RegisteredMcpTool = { |
| 277 | 277 | readonly enable: () => void; |
| 278 | 278 | readonly disable: () => void; |
| @@ -338,7 +338,6 @@ export type NativeExecutionServices< · 59 unmodified lines | ||
| 338 | 338 | */ |
| 339 | 339 | export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = { |
| 340 | 340 | readonly server: Server; |
| 341 | − readonly era: "v1" | "v2"; | |
| 342 | 341 | readonly initialAppsEnabled: boolean; |
| 343 | 342 | readonly getClientCapabilities: () => unknown | null; |
| 344 | 343 | readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean }; |
| @@ -1003,12 +1002,12 @@ const parseJsonContent = (raw: string): Record<string, unknown> | undefined => { · 658 unmodified lines | ||
| 1003 | 1002 | // --------------------------------------------------------------------------- |
| 1004 | 1003 | |
| 1005 | 1004 | /** Assemble the shared Executor tools through one SDK-specific adapter. */ |
| 1006 | −export const createExecutorMcpServerAssembly = < | |
| 1005 | +export const buildExecutorMcpTools = < | |
| 1007 | 1006 | E extends Cause.YieldableError, |
| 1008 | 1007 | Server, |
| 1009 | 1008 | RequestContext extends McpRequestJoinKeys, |
| 1010 | 1009 | >( |
| 1011 | − config: ExecutorMcpServerConfig<E>, | |
| 1010 | + config: ExecutorMcpToolConfig<E>, | |
| 1012 | 1011 | createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>, |
| 1013 | 1012 | ): Effect.Effect<Server> => |
| 1014 | 1013 | Effect.gen(function* () { |
| @@ -2159,17 +2158,6 @@ export const createExecutorMcpServerAssembly = < · 1144 unmodified lines | ||
| 2159 | 2158 | ); |
| 2160 | 2159 | } |
| 2161 | 2160 | |
| 2162 | − if (assembly.era === "v1") { | |
| 2163 | − console.error( | |
| 2164 | − "[executor] MCP session mode", | |
| 2165 | − JSON.stringify({ | |
| 2166 | − clientCapabilities, | |
| 2167 | − elicitationSupport: assembly.getElicitationSupport(), | |
| 2168 | − elicitationMode: elicitationMode.mode, | |
| 2169 | − resumeEnabled: elicitationMode.mode !== "native", | |
| 2170 | − }), | |
| 2171 | − ); | |
| 2172 | − } | |
| 2173 | 2161 | debugLog("tool.visibility", { |
| 2174 | 2162 | clientCapabilities: clientCapabilities ?? null, |
| 2175 | 2163 | elicitationSupport: assembly.getElicitationSupport(), |
| @@ -16,14 +16,14 @@ import { Effect } from "effect"; · 15 unmodified lines | ||
| 16 | 16 | import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; |
| 17 | 17 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; |
| 18 | 18 | |
| 19 | −import { appsEnabledForClientCapabilities, buildMcpServerV2 } from "./tool-server-v2"; | |
| 19 | +import { appsEnabledForClientCapabilities, buildMcpServer } from "./tool-server"; | |
| 20 | 20 | import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps"; |
| 21 | 21 | |
| 22 | 22 | const REQUEST_STATE_KEY = new Uint8Array(32).fill(7); |
| 23 | 23 | const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo"); |
| 24 | 24 | const APP_URI = "ui://executor/shell.html"; |
| 25 | 25 | |
| 26 | −type TestV2Config = { | |
| 26 | +type TestServerConfig = { | |
| 27 | 27 | readonly engine: ExecutionEngine; |
| 28 | 28 | readonly appsEnabled: boolean; |
| 29 | 29 | readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" }; |
| @@ -52,14 +52,14 @@ const makeStubEngine = ( · 22 unmodified lines | ||
| 52 | 52 | }); |
| 53 | 53 | |
| 54 | 54 | const withClient = async ( |
| 55 | − config: TestV2Config, | |
| 55 | + config: TestServerConfig, | |
| 56 | 56 | run: (client: Client) => Promise<void>, |
| 57 | 57 | options?: { readonly manualInputRequired?: boolean }, |
| 58 | 58 | ) => { |
| 59 | 59 | const handler = createMcpHandler( |
| 60 | 60 | () => |
| 61 | 61 | Effect.runPromise( |
| 62 | − buildMcpServerV2({ | |
| 62 | + buildMcpServer({ | |
| 63 | 63 | ...config, |
| 64 | 64 | requestStateSigningKey: REQUEST_STATE_KEY, |
| 65 | 65 | requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test", |
| @@ -74,7 +74,7 @@ const withClient = async ( · 8 unmodified lines | ||
| 74 | 74 | ), |
| 75 | 75 | }); |
| 76 | 76 | const client = new Client( |
| 77 | − { name: "executor-v2-test", version: "1.0.0" }, | |
| 77 | + { name: "executor-protocol-test", version: "1.0.0" }, | |
| 78 | 78 | { |
| 79 | 79 | capabilities: { elicitation: { form: {} } }, |
| 80 | 80 | versionNegotiation: { mode: { pin: "2026-07-28" } }, |
| @@ -101,7 +101,7 @@ const manualToolCall = ( · 20 unmodified lines | ||
| 101 | 101 | }); |
| 102 | 102 | }; |
| 103 | 103 | |
| 104 | −describe("SDK v2 Executor MCP assembly", () => { | |
| 104 | +describe("Executor MCP protocol assembly", () => { | |
| 105 | 105 | it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => { |
| 106 | 106 | await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => { |
| 107 | 107 | const names = (await client.listTools()).tools.map(({ name }) => name); |
| @@ -1,453 +0,0 @@ | ||
| 1 | −/** | |
| 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. | |
| 6 | − */ | |
| 7 | −import { Data, Effect, Match, Option, Schema } from "effect"; | |
| 8 | −import * as Cause from "effect/Cause"; | |
| 9 | −import { | |
| 10 | − acceptedContent, | |
| 11 | − CLIENT_CAPABILITIES_META_KEY, | |
| 12 | − createRequestStateCodec, | |
| 13 | − fromJsonSchema, | |
| 14 | − inputRequired, | |
| 15 | − inputResponse, | |
| 16 | − McpServer, | |
| 17 | − type CallToolResult, | |
| 18 | − type InputRequiredResult, | |
| 19 | − type ServerContext, | |
| 20 | −} from "@modelcontextprotocol/server"; | |
| 21 | −import * as z from "zod/v4"; | |
| 22 | − | |
| 23 | −import type { ElicitationRequest } from "@executor-js/sdk"; | |
| 24 | − | |
| 25 | −import { | |
| 26 | − getUiCapability, | |
| 27 | − EXTENSION_ID, | |
| 28 | − registerAppResource, | |
| 29 | − registerAppTool, | |
| 30 | − RESOURCE_MIME_TYPE, | |
| 31 | − RESOURCE_URI_META_KEY, | |
| 32 | − type McpAppsClientCapabilities, | |
| 33 | − type McpAppToolMeta, | |
| 34 | −} from "./mcp-apps"; | |
| 35 | −import { | |
| 36 | − createExecutorMcpServerAssembly, | |
| 37 | − type ExecutorMcpAssembly, | |
| 38 | − type ExecutorMcpServerConfig, | |
| 39 | − type McpHandlerResult, | |
| 40 | − type McpRequestJoinKeys, | |
| 41 | − type McpToolResult, | |
| 42 | − type NativeExecutionServices, | |
| 43 | −} from "./tool-server-shared"; | |
| 44 | − | |
| 45 | −export type { BrowserApprovalStore, ExecutorMcpServerConfig } from "./tool-server-shared"; | |
| 46 | − | |
| 47 | −const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; | |
| 48 | − | |
| 49 | −const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); | |
| 50 | −/** Verified payload carried by a modern native-elicitation continuation. */ | |
| 51 | −export type NativeRequestState = typeof NativeRequestStateSchema.Type; | |
| 52 | −const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); | |
| 53 | − | |
| 54 | −type V2RequestContext = McpRequestJoinKeys & { | |
| 55 | − readonly serverContext: ServerContext; | |
| 56 | −}; | |
| 57 | − | |
| 58 | −/** Additional serving inputs required by the SDK v2 assembly. */ | |
| 59 | −export type ExecutorMcpServerV2Config<E extends Cause.YieldableError = Cause.YieldableError> = | |
| 60 | − ExecutorMcpServerConfig<E> & { | |
| 61 | − /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */ | |
| 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; | |
| 69 | − /** HMAC key used to sign opaque native-elicitation continuation state. */ | |
| 70 | − readonly requestStateSigningKey: Uint8Array | string; | |
| 71 | − /** | |
| 72 | − * Stable identifier of the authenticated principal this server instance | |
| 73 | − * was built for (org/user/subject). Bound into the signed continuation | |
| 74 | − * state so a `requestState` minted for one principal is rejected when | |
| 75 | − * echoed by another — the spec's user-binding MUST for state that | |
| 76 | − * influences authorization. Single-user hosts pass a constant. | |
| 77 | − */ | |
| 78 | − readonly requestStatePrincipal: string; | |
| 79 | − /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */ | |
| 80 | − readonly requestStateTtlSeconds?: number; | |
| 81 | − }; | |
| 82 | − | |
| 83 | −/** Decide whether client capabilities advertise support for MCP Apps HTML. */ | |
| 84 | −export const appsEnabledForClientCapabilities = ( | |
| 85 | − clientCapabilities: McpAppsClientCapabilities | null | undefined, | |
| 86 | −): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); | |
| 87 | − | |
| 88 | −/** Bind modern continuation state to the ownership identity used by MCP hosts. */ | |
| 89 | −export const mcpRequestStatePrincipal = (principal: { | |
| 90 | − readonly accountId: string; | |
| 91 | − readonly organizationId: string; | |
| 92 | −}): string => `${principal.accountId}\u0000${principal.organizationId}`; | |
| 93 | − | |
| 94 | −const requestStateBinding = (method: string, principal: string): string => | |
| 95 | − `${method}\u0000${principal}`; | |
| 96 | − | |
| 97 | −/** Route-level failure verifying untrusted modern continuation state. */ | |
| 98 | −export class McpRequestStateVerificationError extends Data.TaggedError( | |
| 99 | − "McpRequestStateVerificationError", | |
| 100 | −)<{ readonly cause: unknown }> {} | |
| 101 | − | |
| 102 | −/** | |
| 103 | − * Verify and parse a modern continuation before a stateless worker uses its | |
| 104 | − * execution id for Durable Object routing. | |
| 105 | − */ | |
| 106 | −export const verifyNativeRequestState = (input: { | |
| 107 | − readonly state: string; | |
| 108 | − readonly method: string; | |
| 109 | − readonly requestStateSigningKey: Uint8Array | string; | |
| 110 | − readonly requestStatePrincipal: string; | |
| 111 | −}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => { | |
| 112 | − const codec = createRequestStateCodec<NativeRequestState>({ | |
| 113 | − key: input.requestStateSigningKey, | |
| 114 | − bind: () => requestStateBinding(input.method, input.requestStatePrincipal), | |
| 115 | − }); | |
| 116 | − return Effect.tryPromise({ | |
| 117 | − // The route-level verifier has no handler context. Its codec binding is a | |
| 118 | − // closed value derived from the already-parsed method and principal, so the | |
| 119 | − // SDK callback never observes this inert placeholder. | |
| 120 | − try: async () => { | |
| 121 | − const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]); | |
| 122 | − return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); | |
| 123 | − }, | |
| 124 | − catch: (cause) => new McpRequestStateVerificationError({ cause }), | |
| 125 | − }); | |
| 126 | −}; | |
| 127 | − | |
| 128 | −const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 129 | − typeof value === "object" && value !== null && !Array.isArray(value); | |
| 130 | − | |
| 131 | −const appsClientCapabilitiesFromUnknown = ( | |
| 132 | − capabilities: unknown, | |
| 133 | −): McpAppsClientCapabilities | null => { | |
| 134 | − if (!isRecord(capabilities)) return null; | |
| 135 | − const extensions = capabilities.extensions; | |
| 136 | − if (!isRecord(extensions)) return null; | |
| 137 | − const ui = extensions[EXTENSION_ID]; | |
| 138 | − if (!isRecord(ui)) return null; | |
| 139 | − const mimeTypes = ui.mimeTypes; | |
| 140 | − if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } }; | |
| 141 | − if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) { | |
| 142 | − return null; | |
| 143 | − } | |
| 144 | − return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; | |
| 145 | −}; | |
| 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 | − | |
| 174 | −/** Parse a cloned HTTP request body without consuming the request itself. */ | |
| 175 | −export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> => | |
| 176 | − Effect.tryPromise({ | |
| 177 | − try: () => request.clone().json(), | |
| 178 | − catch: () => null, | |
| 179 | − }).pipe( | |
| 180 | − Effect.match({ | |
| 181 | − onFailure: () => null, | |
| 182 | − onSuccess: (body) => body, | |
| 183 | − }), | |
| 184 | − ); | |
| 185 | − | |
| 186 | −/** | |
| 187 | − * Parse the MCP Apps capability subset from a modern request's `_meta` | |
| 188 | − * envelope without consuming the request body used by the SDK handler. | |
| 189 | − */ | |
| 190 | −export const clientCapabilitiesFromRequest = ( | |
| 191 | − request: Request, | |
| 192 | −): Effect.Effect<McpAppsClientCapabilities | null> => | |
| 193 | − requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody)); | |
| 194 | − | |
| 195 | −const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ | |
| 196 | − requestId: context.mcpReq.id, | |
| 197 | − ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), | |
| 198 | − serverContext: context, | |
| 199 | −}); | |
| 200 | − | |
| 201 | −const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => { | |
| 202 | − const ui = metadata.ui; | |
| 203 | − if (!isRecord(ui)) return undefined; | |
| 204 | − const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined; | |
| 205 | − const visibility = Array.isArray(ui.visibility) | |
| 206 | − ? ui.visibility.filter( | |
| 207 | − (value): value is "model" | "app" => value === "model" || value === "app", | |
| 208 | − ) | |
| 209 | − : undefined; | |
| 210 | − return { | |
| 211 | − ...(resourceUri === undefined ? {} : { resourceUri }), | |
| 212 | − ...(visibility === undefined ? {} : { visibility }), | |
| 213 | − }; | |
| 214 | −}; | |
| 215 | − | |
| 216 | −const normalizedAppMetadata = (metadata: Record<string, unknown>) => { | |
| 217 | − const ui = appToolMeta(metadata); | |
| 218 | − const legacyResourceUri = metadata[RESOURCE_URI_META_KEY]; | |
| 219 | − return { | |
| 220 | − ...metadata, | |
| 221 | − ...(ui === undefined ? {} : { ui }), | |
| 222 | − ...(typeof legacyResourceUri === "string" | |
| 223 | − ? { [RESOURCE_URI_META_KEY]: legacyResourceUri } | |
| 224 | − : {}), | |
| 225 | − }; | |
| 226 | −}; | |
| 227 | − | |
| 228 | −const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => { | |
| 229 | − const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata; | |
| 230 | − return rest; | |
| 231 | −}; | |
| 232 | − | |
| 233 | −const visibilityIncludes = ( | |
| 234 | − metadata: Record<string, unknown>, | |
| 235 | − visibility: "model" | "app", | |
| 236 | −): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true; | |
| 237 | − | |
| 238 | −const v2ToolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result; | |
| 239 | − | |
| 240 | −const elicitationInputRequest = (request: ElicitationRequest) => | |
| 241 | − Match.value(request).pipe( | |
| 242 | − Match.tag("FormElicitation", (form) => | |
| 243 | − inputRequired.elicit({ | |
| 244 | − message: form.message, | |
| 245 | − requestedSchema: | |
| 246 | − Object.keys(form.requestedSchema).length === 0 | |
| 247 | − ? fromJsonSchema({ type: "object" as const, properties: {} }) | |
| 248 | − : fromJsonSchema(form.requestedSchema), | |
| 249 | − }), | |
| 250 | − ), | |
| 251 | − Match.tag("UrlElicitation", (url) => | |
| 252 | − inputRequired.elicitUrl({ message: url.message, url: url.url }), | |
| 253 | − ), | |
| 254 | − Match.exhaustive, | |
| 255 | − ); | |
| 256 | − | |
| 257 | −const missingNativeExecution = (executionId: string): McpToolResult => ({ | |
| 258 | − content: [ | |
| 259 | − { | |
| 260 | − type: "text", | |
| 261 | − text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`, | |
| 262 | − }, | |
| 263 | − ], | |
| 264 | − structuredContent: { | |
| 265 | − status: "execution_not_found", | |
| 266 | − executionId, | |
| 267 | − }, | |
| 268 | − isError: true, | |
| 269 | −}); | |
| 270 | − | |
| 271 | −const createV2Assembly = <E extends Cause.YieldableError>( | |
| 272 | − config: ExecutorMcpServerV2Config<E>, | |
| 273 | −): ExecutorMcpAssembly<McpServer, V2RequestContext> => { | |
| 274 | − const sessionful = config.sessionful ?? false; | |
| 275 | − const initialAppsEnabled = sessionful | |
| 276 | − ? (config.restoredAppsEnabled ?? config.appsEnabled) | |
| 277 | − : config.appsEnabled; | |
| 278 | − const requestStateCodec = createRequestStateCodec<NativeRequestState>({ | |
| 279 | − key: config.requestStateSigningKey, | |
| 280 | − ...(config.requestStateTtlSeconds === undefined | |
| 281 | − ? {} | |
| 282 | − : { ttlSeconds: config.requestStateTtlSeconds }), | |
| 283 | − bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal), | |
| 284 | − }); | |
| 285 | − const verifyRequestState = async (state: string, context: ServerContext) => { | |
| 286 | − const decoded = await requestStateCodec.verify(state, context); | |
| 287 | − return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); | |
| 288 | − }; | |
| 289 | − const server = new McpServer( | |
| 290 | − { name: "executor", version: "1.0.0" }, | |
| 291 | − { | |
| 292 | − capabilities: { resources: {}, tools: {} }, | |
| 293 | − requestState: { verify: verifyRequestState }, | |
| 294 | − }, | |
| 295 | − ); | |
| 296 | − | |
| 297 | − const registerTool: ExecutorMcpAssembly<McpServer, V2RequestContext>["registerTool"] = ( | |
| 298 | − name, | |
| 299 | − toolConfig, | |
| 300 | − callback, | |
| 301 | − ) => { | |
| 302 | − const inputSchema = z.object(toolConfig.inputSchema); | |
| 303 | − return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( | |
| 304 | − name, | |
| 305 | − { ...toolConfig, inputSchema }, | |
| 306 | − async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))), | |
| 307 | − ); | |
| 308 | − }; | |
| 309 | − | |
| 310 | − const registerApp: ExecutorMcpAssembly<McpServer, V2RequestContext>["registerAppTool"] = ( | |
| 311 | − name, | |
| 312 | − toolConfig, | |
| 313 | − callback, | |
| 314 | − ) => { | |
| 315 | − const inputSchema = z.object(toolConfig.inputSchema); | |
| 316 | − const metadata = normalizedAppMetadata(toolConfig._meta); | |
| 317 | − if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) { | |
| 318 | − const plainMetadata = withoutAppMetadata(metadata); | |
| 319 | − return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( | |
| 320 | − name, | |
| 321 | − { | |
| 322 | − ...toolConfig, | |
| 323 | − inputSchema, | |
| 324 | − ...(Object.keys(plainMetadata).length === 0 | |
| 325 | − ? { _meta: undefined } | |
| 326 | − : { _meta: plainMetadata }), | |
| 327 | − }, | |
| 328 | − async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))), | |
| 329 | − ); | |
| 330 | − } | |
| 331 | − | |
| 332 | − return registerAppTool<typeof inputSchema, z.ZodObject<z.ZodRawShape>>( | |
| 333 | − server, | |
| 334 | − name, | |
| 335 | − { ...toolConfig, inputSchema, _meta: metadata }, | |
| 336 | − async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))), | |
| 337 | − ); | |
| 338 | − }; | |
| 339 | − | |
| 340 | − const nativeInputRequired = async <NativeE extends Cause.YieldableError>( | |
| 341 | − services: NativeExecutionServices<NativeE, V2RequestContext>, | |
| 342 | − execution: Parameters<NativeExecutionServices<NativeE, V2RequestContext>["executionPaused"]>[0], | |
| 343 | − ): Promise<InputRequiredResult> => { | |
| 344 | − const requestState = await requestStateCodec.mint( | |
| 345 | − { executionId: execution.id }, | |
| 346 | − services.requestContext.serverContext, | |
| 347 | − ); | |
| 348 | − return inputRequired({ | |
| 349 | − inputRequests: { | |
| 350 | − [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest( | |
| 351 | − execution.elicitationContext.request, | |
| 352 | − ), | |
| 353 | − }, | |
| 354 | − requestState, | |
| 355 | − }); | |
| 356 | − }; | |
| 357 | − | |
| 358 | − return { | |
| 359 | − server, | |
| 360 | − era: "v2", | |
| 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 | − }, | |
| 377 | − registerTool, | |
| 378 | − registerAppTool: registerApp, | |
| 379 | − registerAppResource: (name, uri, resourceConfig, callback) => { | |
| 380 | − if (!sessionful && !config.appsEnabled) return; | |
| 381 | − registerAppResource(server, name, uri, resourceConfig, async () => { | |
| 382 | − const result = await callback(); | |
| 383 | − return { contents: [...result.contents] }; | |
| 384 | − }); | |
| 385 | − }, | |
| 386 | − executeNative: <NativeE extends Cause.YieldableError>( | |
| 387 | − services: NativeExecutionServices<NativeE, V2RequestContext>, | |
| 388 | − ) => | |
| 389 | − Effect.gen(function* () { | |
| 390 | − const decodedState = decodeNativeRequestState( | |
| 391 | − services.requestContext.serverContext.mcpReq.requestState<unknown>(), | |
| 392 | − ); | |
| 393 | − | |
| 394 | − if (Option.isSome(decodedState)) { | |
| 395 | − const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId); | |
| 396 | − if (!paused) return missingNativeExecution(decodedState.value.executionId); | |
| 397 | − const response = inputResponse( | |
| 398 | − services.requestContext.serverContext.mcpReq.inputResponses, | |
| 399 | − NATIVE_ELICITATION_RESPONSE_KEY, | |
| 400 | − ); | |
| 401 | − if (response.kind === "elicit") { | |
| 402 | − const content = Match.value(paused.elicitationContext.request).pipe( | |
| 403 | − Match.tag("UrlElicitation", () => response.content), | |
| 404 | − Match.tag("FormElicitation", (form) => | |
| 405 | − response.action === "accept" | |
| 406 | − ? acceptedContent( | |
| 407 | − services.requestContext.serverContext.mcpReq.inputResponses, | |
| 408 | − NATIVE_ELICITATION_RESPONSE_KEY, | |
| 409 | − fromJsonSchema<Record<string, unknown>>( | |
| 410 | − Object.keys(form.requestedSchema).length === 0 | |
| 411 | − ? { type: "object", properties: {} } | |
| 412 | − : form.requestedSchema, | |
| 413 | − ), | |
| 414 | − ) | |
| 415 | − : response.content, | |
| 416 | − ), | |
| 417 | − Match.exhaustive, | |
| 418 | − ); | |
| 419 | − if (response.action === "accept" && content === undefined) { | |
| 420 | − return yield* Effect.promise(() => nativeInputRequired(services, paused)); | |
| 421 | − } | |
| 422 | − const outcome = yield* services.resume(decodedState.value.executionId, { | |
| 423 | − action: response.action, | |
| 424 | − content, | |
| 425 | − }); | |
| 426 | − if (!outcome) return missingNativeExecution(decodedState.value.executionId); | |
| 427 | − if (outcome.status === "completed") return services.complete(outcome.result); | |
| 428 | − yield* services.executionPaused(outcome.execution); | |
| 429 | − return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); | |
| 430 | − } | |
| 431 | − | |
| 432 | − return yield* Effect.promise(() => nativeInputRequired(services, paused)); | |
| 433 | − } | |
| 434 | − | |
| 435 | − const outcome = yield* services.engine.executeWithPause(services.code); | |
| 436 | − if (outcome.status === "completed") return services.complete(outcome.result); | |
| 437 | − yield* services.executionPaused(outcome.execution); | |
| 438 | − return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); | |
| 439 | − }), | |
| 440 | − }; | |
| 441 | −}; | |
| 442 | − | |
| 443 | −/** | |
| 444 | − * Build one SDK v2 Executor MCP server. | |
| 445 | − * | |
| 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. | |
| 449 | − */ | |
| 450 | −export const buildMcpServerV2 = <E extends Cause.YieldableError>( | |
| 451 | − config: ExecutorMcpServerV2Config<E>, | |
| 452 | −): Effect.Effect<McpServer> => | |
| 453 | − createExecutorMcpServerAssembly(config, () => createV2Assembly(config)); | |
| @@ -1,10 +1,8 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { Data, Deferred, Effect } from "effect"; |
| 3 | 3 | import type * as Tracer from "effect/Tracer"; |
| 4 | −import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 5 | −import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | |
| 6 | −import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | |
| 7 | −import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | |
| 4 | +import { Client } from "@modelcontextprotocol/client"; | |
| 5 | +import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; | |
| 8 | 6 | import type * as Cause from "effect/Cause"; |
| 9 | 7 | |
| 10 | 8 | import { |
| @@ -18,7 +16,7 @@ import type { ToolFileValue } from "@executor-js/sdk"; · 7 unmodified lines | ||
| 18 | 16 | import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; |
| 19 | 17 | |
| 20 | 18 | import { |
| 21 | − createExecutorMcpServer, | |
| 19 | + buildMcpServer, | |
| 22 | 20 | formatMcpExecutionOutcome, |
| 23 | 21 | type ExecutorMcpServerConfig, |
| 24 | 22 | } from "./tool-server"; |
| @@ -45,18 +43,25 @@ const makeStubEngine = <E extends Cause.YieldableError = never>(overrides: { · 20 unmodified lines | ||
| 45 | 43 | resume?: ExecutionEngine<E>["resume"]; |
| 46 | 44 | isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"]; |
| 47 | 45 | description?: string; |
| 48 | −}): ExecutionEngine<E> => ({ | |
| 49 | − execute: overrides.execute ?? (() => Effect.succeed({ result: "default" })), | |
| 50 | − executeWithPause: | |
| 51 | − overrides.executeWithPause ?? | |
| 52 | − (() => Effect.succeed({ status: "completed", result: { result: "default" } })), | |
| 53 | − resume: overrides.resume ?? (() => Effect.succeed(null)), | |
| 54 | − isExecutionSettled: overrides.isExecutionSettled, | |
| 55 | − getPausedExecution: () => Effect.succeed(null), | |
| 56 | − pausedExecutionCount: () => Effect.succeed(0), | |
| 57 | − hasPausedExecutions: () => Effect.succeed(false), | |
| 58 | − getDescription: Effect.succeed(overrides.description ?? "test executor"), | |
| 59 | −}); | |
| 46 | +}): ExecutionEngine<E> => { | |
| 47 | + const execute: ExecutionEngine<E>["execute"] = | |
| 48 | + overrides.execute ?? (() => Effect.succeed({ result: "default" })); | |
| 49 | + return { | |
| 50 | + execute, | |
| 51 | + executeWithPause: | |
| 52 | + overrides.executeWithPause ?? | |
| 53 | + ((code) => | |
| 54 | + execute(code, { | |
| 55 | + onElicitation: () => Effect.die("Unexpected elicitation in completed execution test"), | |
| 56 | + }).pipe(Effect.map((result) => ({ status: "completed" as const, result })))), | |
| 57 | + resume: overrides.resume ?? (() => Effect.succeed(null)), | |
| 58 | + isExecutionSettled: overrides.isExecutionSettled, | |
| 59 | + getPausedExecution: () => Effect.succeed(null), | |
| 60 | + pausedExecutionCount: () => Effect.succeed(0), | |
| 61 | + hasPausedExecutions: () => Effect.succeed(false), | |
| 62 | + getDescription: Effect.succeed(overrides.description ?? "test executor"), | |
| 63 | + }; | |
| 64 | +}; | |
| 60 | 65 | |
| 61 | 66 | type TestServerConfig<E extends Cause.YieldableError> = Pick< |
| 62 | 67 | ExecutorMcpServerConfig<E>, |
| @@ -76,7 +81,14 @@ const withClient = async <E extends Cause.YieldableError>( · 13 unmodified lines | ||
| 76 | 81 | config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer }, |
| 77 | 82 | ) => { |
| 78 | 83 | const { tracer, ...serverConfig } = config ?? {}; |
| 79 | − const create = createExecutorMcpServer({ engine, ...serverConfig }); | |
| 84 | + const create = buildMcpServer({ | |
| 85 | + engine, | |
| 86 | + appsEnabled: false, | |
| 87 | + requestStateSigningKey: new Uint8Array(32).fill(13), | |
| 88 | + requestStatePrincipal: "tool-server-test-principal", | |
| 89 | + sessionful: true, | |
| 90 | + ...serverConfig, | |
| 91 | + }); | |
| 80 | 92 | const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create); |
| 81 | 93 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| 82 | 94 | const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities }); |
| @@ -154,7 +166,6 @@ const withTracedClient = async <E extends Cause.YieldableError>( · 71 unmodified lines | ||
| 154 | 166 | const ELICITATION_CAPS: ClientCapabilities = { |
| 155 | 167 | elicitation: { form: {}, url: {} }, |
| 156 | 168 | }; |
| 157 | −const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } }; | |
| 158 | 169 | const NO_CAPS: ClientCapabilities = {}; |
| 159 | 170 | |
| 160 | 171 | /** Extract the first text content from a callTool result. */ |
| @@ -192,33 +203,15 @@ const toolFile = (input: { · 31 unmodified lines | ||
| 192 | 203 | byteLength: input.byteLength, |
| 193 | 204 | }); |
| 194 | 205 | |
| 195 | −/** Build an engine whose execute triggers one elicitation and returns the handler's result. */ | |
| 196 | −const makeElicitingEngine = ( | |
| 197 | − request: FormElicitation | UrlElicitation, | |
| 198 | − formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = ( | |
| 199 | − r, | |
| 200 | − ) => r.action, | |
| 201 | −): ExecutionEngine => | |
| 202 | − makeStubEngine({ | |
| 203 | − execute: (_code, { onElicitation }) => | |
| 204 | − Effect.gen(function* () { | |
| 205 | − const response = yield* onElicitation({ | |
| 206 | − address: STUB_TOOL_ADDRESS, | |
| 207 | − args: {}, | |
| 208 | − request, | |
| 209 | − }); | |
| 210 | − return { result: formatResult(response) }; | |
| 211 | − }), | |
| 212 | − }); | |
| 213 | − | |
| 214 | 206 | // --------------------------------------------------------------------------- |
| 215 | 207 | // Explicit native elicitation mode |
| 216 | 208 | // --------------------------------------------------------------------------- |
| 217 | 209 | |
| 218 | 210 | describe("MCP host server — native elicitation mode", () => { |
| 219 | − it("execute tool calls engine.execute and returns result", async () => { | |
| 211 | + it("execute tool calls engine.executeWithPause and returns result", async () => { | |
| 220 | 212 | const engine = makeStubEngine({ |
| 221 | − execute: (code) => Effect.succeed({ result: `ran: ${code}` }), | |
| 213 | + executeWithPause: (code) => | |
| 214 | + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), | |
| 222 | 215 | }); |
| 223 | 216 | |
| 224 | 217 | await withNativeClient(engine, ELICITATION_CAPS, async (client) => { |
| @@ -769,52 +762,6 @@ describe("MCP host server — native elicitation mode", () => { · 544 unmodified lines | ||
| 769 | 762 | }); |
| 770 | 763 | }); |
| 771 | 764 | |
| 772 | − it("form elicitation is bridged from engine to MCP client and back", async () => { | |
| 773 | − const engine = makeElicitingEngine( | |
| 774 | − FormElicitation.make({ | |
| 775 | − message: "Approve this action?", | |
| 776 | − requestedSchema: { | |
| 777 | − type: "object", | |
| 778 | − properties: { approved: { type: "boolean" } }, | |
| 779 | − }, | |
| 780 | − }), | |
| 781 | − (r) => (r.action === "accept" && r.content?.approved ? "approved" : "denied"), | |
| 782 | − ); | |
| 783 | − | |
| 784 | − await withNativeClient(engine, ELICITATION_CAPS, async (client) => { | |
| 785 | − client.setRequestHandler(ElicitRequestSchema, async () => ({ | |
| 786 | − action: "accept" as const, | |
| 787 | − content: { approved: true }, | |
| 788 | − })); | |
| 789 | − | |
| 790 | − const result = await client.callTool({ | |
| 791 | − name: "execute", | |
| 792 | − arguments: { code: "do-it" }, | |
| 793 | − }); | |
| 794 | − expect(result.content).toEqual([{ type: "text", text: "approved" }]); | |
| 795 | − }); | |
| 796 | − }); | |
| 797 | − | |
| 798 | − it("form elicitation declined by client → engine sees decline", async () => { | |
| 799 | − const engine = makeElicitingEngine( | |
| 800 | − FormElicitation.make({ message: "Accept?", requestedSchema: {} }), | |
| 801 | − (r) => `action:${r.action}`, | |
| 802 | − ); | |
| 803 | − | |
| 804 | − await withNativeClient(engine, ELICITATION_CAPS, async (client) => { | |
| 805 | − client.setRequestHandler(ElicitRequestSchema, async () => ({ | |
| 806 | − action: "decline" as const, | |
| 807 | − content: {}, | |
| 808 | − })); | |
| 809 | − | |
| 810 | − const result = await client.callTool({ | |
| 811 | − name: "execute", | |
| 812 | − arguments: { code: "x" }, | |
| 813 | − }); | |
| 814 | − expect(result.content).toEqual([{ type: "text", text: "action:decline" }]); | |
| 815 | − }); | |
| 816 | − }); | |
| 817 | − | |
| 818 | 765 | it("browser approval mode does not auto-switch to native elicitation", async () => { |
| 819 | 766 | let approvalUrlCalled = false; |
| 820 | 767 | let executeCalled = false; |
| @@ -837,11 +784,6 @@ describe("MCP host server — native elicitation mode", () => { · 16 unmodified lines | ||
| 837 | 784 | engine, |
| 838 | 785 | ELICITATION_CAPS, |
| 839 | 786 | async (client) => { |
| 840 | − client.setRequestHandler(ElicitRequestSchema, async () => ({ | |
| 841 | − action: "accept" as const, | |
| 842 | − content: {}, | |
| 843 | − })); | |
| 844 | − | |
| 845 | 787 | const { tools } = await client.listTools(); |
| 846 | 788 | expect(tools.map((t) => t.name)).toContain("resume"); |
| 847 | 789 | |
| @@ -870,56 +812,6 @@ describe("MCP host server — native elicitation mode", () => { · 22 unmodified lines | ||
| 870 | 812 | ); |
| 871 | 813 | }); |
| 872 | 814 | |
| 873 | − it("empty form schema gets wrapped with minimal valid schema", async () => { | |
| 874 | − let receivedSchema: unknown; | |
| 875 | − const engine = makeElicitingEngine( | |
| 876 | − FormElicitation.make({ message: "Just approve", requestedSchema: {} }), | |
| 877 | − ); | |
| 878 | − | |
| 879 | − await withNativeClient(engine, ELICITATION_CAPS, async (client) => { | |
| 880 | − client.setRequestHandler(ElicitRequestSchema, async (request) => { | |
| 881 | − const params = request.params; | |
| 882 | − if ("requestedSchema" in params) { | |
| 883 | − receivedSchema = params.requestedSchema; | |
| 884 | − } | |
| 885 | − return { action: "accept" as const, content: {} }; | |
| 886 | − }); | |
| 887 | − | |
| 888 | − await client.callTool({ | |
| 889 | − name: "execute", | |
| 890 | − arguments: { code: "approve" }, | |
| 891 | − }); | |
| 892 | − expect(receivedSchema).toEqual({ type: "object", properties: {} }); | |
| 893 | − }); | |
| 894 | − }); | |
| 895 | − | |
| 896 | − it("UrlElicitation is sent as native mode:url elicitation", async () => { | |
| 897 | − let receivedParams: Record<string, unknown> | undefined; | |
| 898 | − const engine = makeElicitingEngine( | |
| 899 | − UrlElicitation.make({ | |
| 900 | − message: "Please authenticate", | |
| 901 | − url: "https://example.com/oauth", | |
| 902 | − elicitationId: ElicitationId.make("elic-1"), | |
| 903 | − }), | |
| 904 | − ); | |
| 905 | − | |
| 906 | − await withNativeClient(engine, ELICITATION_CAPS, async (client) => { | |
| 907 | − client.setRequestHandler(ElicitRequestSchema, async (request) => { | |
| 908 | − receivedParams = request.params as Record<string, unknown>; | |
| 909 | − return { action: "accept" as const, content: {} }; | |
| 910 | − }); | |
| 911 | − | |
| 912 | − await client.callTool({ | |
| 913 | − name: "execute", | |
| 914 | − arguments: { code: "oauth" }, | |
| 915 | − }); | |
| 916 | − expect(receivedParams?.mode).toBe("url"); | |
| 917 | − expect(receivedParams?.message).toBe("Please authenticate"); | |
| 918 | − expect(receivedParams?.url).toBe("https://example.com/oauth"); | |
| 919 | − expect(receivedParams?.elicitationId).toBe("elic-1"); | |
| 920 | − }); | |
| 921 | − }); | |
| 922 | − | |
| 923 | 815 | it("engine error is surfaced as isError result", async () => { |
| 924 | 816 | const engine = makeStubEngine({ |
| 925 | 817 | execute: () => |
| @@ -977,61 +869,6 @@ describe("MCP host server — native elicitation mode", () => { · 51 unmodified lines | ||
| 977 | 869 | }); |
| 978 | 870 | }); |
| 979 | 871 | |
| 980 | −// --------------------------------------------------------------------------- | |
| 981 | −// Client with form-only elicitation in native mode | |
| 982 | −// --------------------------------------------------------------------------- | |
| 983 | − | |
| 984 | −describe("MCP host server — native form-only elicitation", () => { | |
| 985 | − it("resume tool is hidden in native mode", async () => { | |
| 986 | − await withNativeClient(makeStubEngine({}), FORM_ONLY_CAPS, async (client) => { | |
| 987 | − const { tools } = await client.listTools(); | |
| 988 | − expect(tools.map((t) => t.name)).toContain("execute"); | |
| 989 | − expect(tools.map((t) => t.name)).not.toContain("resume"); | |
| 990 | − }); | |
| 991 | − }); | |
| 992 | − | |
| 993 | − it("uses native elicitation path when client supports form", async () => { | |
| 994 | − const engine = makeStubEngine({ | |
| 995 | − execute: (code) => Effect.succeed({ result: `native: ${code}` }), | |
| 996 | − }); | |
| 997 | − | |
| 998 | − await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => { | |
| 999 | − const result = await client.callTool({ | |
| 1000 | − name: "execute", | |
| 1001 | − arguments: { code: "test" }, | |
| 1002 | − }); | |
| 1003 | − expect(result.content).toEqual([{ type: "text", text: "native: test" }]); | |
| 1004 | − }); | |
| 1005 | − }); | |
| 1006 | − | |
| 1007 | − it("UrlElicitation falls back to form when client lacks url support", async () => { | |
| 1008 | − let receivedMessage: string | undefined; | |
| 1009 | − const engine = makeElicitingEngine( | |
| 1010 | − UrlElicitation.make({ | |
| 1011 | − message: "Please authenticate", | |
| 1012 | − url: "https://auth.example.com/oauth", | |
| 1013 | − elicitationId: ElicitationId.make("elic-1"), | |
| 1014 | − }), | |
| 1015 | − ); | |
| 1016 | − | |
| 1017 | − await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => { | |
| 1018 | − client.setRequestHandler(ElicitRequestSchema, async (request) => { | |
| 1019 | − receivedMessage = | |
| 1020 | − typeof request.params.message === "string" ? request.params.message : undefined; | |
| 1021 | − return { action: "accept" as const, content: {} }; | |
| 1022 | − }); | |
| 1023 | − | |
| 1024 | − const result = await client.callTool({ | |
| 1025 | − name: "execute", | |
| 1026 | − arguments: { code: "oauth" }, | |
| 1027 | − }); | |
| 1028 | − expect(result.content).toEqual([{ type: "text", text: "accept" }]); | |
| 1029 | − expect(receivedMessage).toContain("https://auth.example.com/oauth"); | |
| 1030 | − expect(receivedMessage).toContain("Please authenticate"); | |
| 1031 | − }); | |
| 1032 | − }); | |
| 1033 | −}); | |
| 1034 | − | |
| 1035 | 872 | // --------------------------------------------------------------------------- |
| 1036 | 873 | // Client WITHOUT elicitation (pause/resume path) |
| 1037 | 874 | // --------------------------------------------------------------------------- |
| @@ -1639,46 +1476,6 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 601 unmodified lines | ||
| 1639 | 1476 | }); |
| 1640 | 1477 | }); |
| 1641 | 1478 | |
| 1642 | −// --------------------------------------------------------------------------- | |
| 1643 | −// Elicitation error handling | |
| 1644 | −// --------------------------------------------------------------------------- | |
| 1645 | − | |
| 1646 | −describe("MCP host server — elicitation error handling", () => { | |
| 1647 | − it("elicitInput failure is not reported as user cancellation", async () => { | |
| 1648 | − const engine = makeElicitingEngine( | |
| 1649 | − FormElicitation.make({ | |
| 1650 | − message: "will fail", | |
| 1651 | − requestedSchema: { | |
| 1652 | − type: "object", | |
| 1653 | − properties: { x: { type: "string" } }, | |
| 1654 | − }, | |
| 1655 | − }), | |
| 1656 | − (r) => `fallback:${r.action}`, | |
| 1657 | − ); | |
| 1658 | − | |
| 1659 | − await withNativeClient(engine, ELICITATION_CAPS, async (client) => { | |
| 1660 | − client.setRequestHandler(ElicitRequestSchema, async () => { | |
| 1661 | − // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: MCP client request handler rejects to exercise server fallback | |
| 1662 | − throw new Error("client cannot handle this"); | |
| 1663 | − }); | |
| 1664 | − | |
| 1665 | − const result = await client.callTool({ | |
| 1666 | − name: "execute", | |
| 1667 | − arguments: { code: "fail" }, | |
| 1668 | − }); | |
| 1669 | − expect(result.isError).toBe(true); | |
| 1670 | − expect(textOf(result)).toMatch( | |
| 1671 | − /^Error: Native elicitation transport failed \[[0-9a-f]{8}\]\. Reconnect the MCP client and try again\.$/, | |
| 1672 | − ); | |
| 1673 | − expect(result.structuredContent).toMatchObject({ | |
| 1674 | − status: "error", | |
| 1675 | − errorCode: "native_elicitation_transport_failed", | |
| 1676 | − }); | |
| 1677 | − expect(textOf(result)).not.toContain("fallback:cancel"); | |
| 1678 | − }); | |
| 1679 | − }); | |
| 1680 | −}); | |
| 1681 | − | |
| 1682 | 1479 | // --------------------------------------------------------------------------- |
| 1683 | 1480 | // Resume content parsing edge cases |
| 1684 | 1481 | // --------------------------------------------------------------------------- |
| @@ -1736,65 +1533,6 @@ describe("MCP host server — resume content parsing", () => { · 51 unmodified lines | ||
| 1736 | 1533 | }); |
| 1737 | 1534 | }); |
| 1738 | 1535 | |
| 1739 | −// --------------------------------------------------------------------------- | |
| 1740 | −// Multiple elicitations in a single execution | |
| 1741 | −// --------------------------------------------------------------------------- | |
| 1742 | − | |
| 1743 | −describe("MCP host server — multiple elicitations", () => { | |
| 1744 | − it("engine can elicit multiple times during a single execute call", async () => { | |
| 1745 | − const engine = makeStubEngine({ | |
| 1746 | − execute: (_code, { onElicitation }) => | |
| 1747 | − Effect.gen(function* () { | |
| 1748 | − const r1 = yield* onElicitation({ | |
| 1749 | − address: STUB_TOOL_ADDRESS, | |
| 1750 | − args: {}, | |
| 1751 | − request: FormElicitation.make({ | |
| 1752 | − message: "What is your name?", | |
| 1753 | − requestedSchema: { | |
| 1754 | − type: "object", | |
| 1755 | − properties: { name: { type: "string" } }, | |
| 1756 | − }, | |
| 1757 | − }), | |
| 1758 | − }); | |
| 1759 | − | |
| 1760 | − const r2 = yield* onElicitation({ | |
| 1761 | − address: STUB_TOOL_ADDRESS, | |
| 1762 | − args: {}, | |
| 1763 | − request: FormElicitation.make({ | |
| 1764 | − message: `Confirm: ${r1.content?.name}?`, | |
| 1765 | − requestedSchema: { | |
| 1766 | − type: "object", | |
| 1767 | − properties: { confirmed: { type: "boolean" } }, | |
| 1768 | − }, | |
| 1769 | − }), | |
| 1770 | − }); | |
| 1771 | − | |
| 1772 | − return { | |
| 1773 | − result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`, | |
| 1774 | − }; | |
| 1775 | − }), | |
| 1776 | − }); | |
| 1777 | − | |
| 1778 | − await withNativeClient(engine, ELICITATION_CAPS, async (client) => { | |
| 1779 | − let callCount = 0; | |
| 1780 | − client.setRequestHandler(ElicitRequestSchema, async () => { | |
| 1781 | − callCount++; | |
| 1782 | − if (callCount === 1) { | |
| 1783 | − return { action: "accept" as const, content: { name: "Alice" } }; | |
| 1784 | − } | |
| 1785 | − return { action: "accept" as const, content: { confirmed: true } }; | |
| 1786 | − }); | |
| 1787 | − | |
| 1788 | − const result = await client.callTool({ | |
| 1789 | − name: "execute", | |
| 1790 | − arguments: { code: "multi" }, | |
| 1791 | − }); | |
| 1792 | − expect(result.content).toEqual([{ type: "text", text: "name=Alice,confirmed=true" }]); | |
| 1793 | − expect(callCount).toBe(2); | |
| 1794 | − }); | |
| 1795 | − }); | |
| 1796 | −}); | |
| 1797 | − | |
| 1798 | 1536 | // --------------------------------------------------------------------------- |
| 1799 | 1537 | // skills tool |
| 1800 | 1538 | // --------------------------------------------------------------------------- |
| @@ -1,225 +1,310 @@ | ||
| 1 | −import { Data, Effect, Match } from "effect"; | |
| 1 | +/** | |
| 2 | + * MCP server 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. | |
| 6 | + */ | |
| 7 | +import { Data, Effect, Match, Option, Schema } from "effect"; | |
| 2 | 8 | import * as Cause from "effect/Cause"; |
| 3 | −import { Validator } from "@cfworker/json-schema"; | |
| 4 | 9 | import { |
| 5 | − getUiCapability, | |
| 6 | − registerAppResource, | |
| 7 | − registerAppTool, | |
| 8 | −} from "@modelcontextprotocol/ext-apps/server"; | |
| 9 | −import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| 10 | −import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | |
| 11 | −import type { | |
| 12 | − jsonSchemaValidator, | |
| 13 | − JsonSchemaType, | |
| 14 | − JsonSchemaValidator, | |
| 15 | −} from "@modelcontextprotocol/sdk/validation/types.js"; | |
| 10 | + acceptedContent, | |
| 11 | + CLIENT_CAPABILITIES_META_KEY, | |
| 12 | + createRequestStateCodec, | |
| 13 | + fromJsonSchema, | |
| 14 | + inputRequired, | |
| 15 | + inputResponse, | |
| 16 | + McpServer, | |
| 17 | + type CallToolResult, | |
| 18 | + type InputRequiredResult, | |
| 19 | + type ServerContext, | |
| 20 | +} from "@modelcontextprotocol/server"; | |
| 16 | 21 | import * as z from "zod/v4"; |
| 17 | 22 | |
| 18 | −import type { ElicitationContext, ElicitationHandler, ElicitationRequest } from "@executor-js/sdk"; | |
| 19 | −import { ElicitationResponse } from "@executor-js/sdk"; | |
| 23 | +import type { ElicitationRequest } from "@executor-js/sdk"; | |
| 20 | 24 | |
| 21 | 25 | import { |
| 22 | − createExecutorMcpServerAssembly, | |
| 23 | − elicitationRequestTag, | |
| 26 | + getUiCapability, | |
| 27 | + EXTENSION_ID, | |
| 28 | + registerAppResource, | |
| 29 | + registerAppTool, | |
| 30 | + RESOURCE_MIME_TYPE, | |
| 31 | + RESOURCE_URI_META_KEY, | |
| 32 | + type McpAppsClientCapabilities, | |
| 33 | + type McpAppToolMeta, | |
| 34 | +} from "./mcp-apps"; | |
| 35 | +import { | |
| 36 | + buildExecutorMcpTools, | |
| 24 | 37 | type ExecutorMcpAssembly, |
| 25 | − type ExecutorMcpServerConfig, | |
| 38 | + type ExecutorMcpToolConfig, | |
| 26 | 39 | type McpHandlerResult, |
| 27 | 40 | type McpRequestJoinKeys, |
| 28 | 41 | type McpToolResult, |
| 29 | 42 | type NativeExecutionServices, |
| 30 | −} from "./tool-server-shared"; | |
| 43 | +} from "./tool-server-core"; | |
| 31 | 44 | |
| 32 | −export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-shared"; | |
| 45 | +export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-core"; | |
| 33 | 46 | export type { |
| 34 | 47 | BrowserApprovalStore, |
| 35 | − ExecutorMcpServerConfig, | |
| 48 | + ExecutorMcpToolConfig, | |
| 36 | 49 | McpArtifactsPort, |
| 37 | 50 | McpConnectionsPort, |
| 38 | 51 | McpToolResult, |
| 39 | 52 | PausedExecutionHooks, |
| 40 | 53 | ResumeFallbackOutcome, |
| 41 | 54 | ResumeUnavailableStatus, |
| 42 | −} from "./tool-server-shared"; | |
| 43 | − | |
| 44 | −// Workers-compatible JSON Schema validator (replaces Ajv, which uses new Function()). | |
| 45 | −class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { | |
| 46 | − getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> { | |
| 47 | − const validator = new Validator(schema as Record<string, unknown>, "2020-12", false); | |
| 48 | − return (input: unknown) => { | |
| 49 | − const result = validator.validate(input); | |
| 50 | − if (result.valid) { | |
| 51 | − return { valid: true, data: input as T, errorMessage: undefined }; | |
| 52 | − } | |
| 53 | − const errorMessage = result.errors | |
| 54 | − .map((error) => `${error.instanceLocation}: ${error.error}`) | |
| 55 | − .join("; "); | |
| 56 | − return { valid: false, data: undefined, errorMessage }; | |
| 57 | − }; | |
| 58 | − } | |
| 59 | −} | |
| 60 | − | |
| 61 | −class McpNativeElicitationTransportError extends Data.TaggedError( | |
| 62 | − "McpNativeElicitationTransportError", | |
| 63 | −)<{ | |
| 64 | − readonly cause: unknown; | |
| 65 | −}> {} | |
| 66 | − | |
| 67 | −type ElicitInputParams = | |
| 68 | − | { | |
| 69 | − mode?: "form"; | |
| 70 | − message: string; | |
| 71 | − requestedSchema: { readonly [key: string]: unknown }; | |
| 72 | − } | |
| 73 | − | { mode: "url"; message: string; url: string; elicitationId: string }; | |
| 55 | +} from "./tool-server-core"; | |
| 74 | 56 | |
| 75 | −const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean => | |
| 76 | − Match.value(request).pipe( | |
| 77 | − Match.tag("FormElicitation", (form) => Object.keys(form.requestedSchema).length > 0), | |
| 78 | − Match.tag("UrlElicitation", () => false), | |
| 79 | − Match.exhaustive, | |
| 80 | − ); | |
| 57 | +const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; | |
| 81 | 58 | |
| 82 | −const elicitationRequestUrl = (request: ElicitationRequest): string | undefined => | |
| 83 | − Match.value(request).pipe( | |
| 84 | − Match.tag("UrlElicitation", (url): string | undefined => url.url), | |
| 85 | − Match.tag("FormElicitation", (): string | undefined => undefined), | |
| 86 | − Match.exhaustive, | |
| 87 | − ); | |
| 59 | +const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); | |
| 60 | +/** Verified payload carried by a modern native-elicitation continuation. */ | |
| 61 | +export type NativeRequestState = typeof NativeRequestStateSchema.Type; | |
| 62 | +const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); | |
| 88 | 63 | |
| 89 | −const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams = | |
| 90 | − Match.type<ElicitationRequest>().pipe( | |
| 91 | − Match.tag("UrlElicitation", (url) => ({ | |
| 92 | − mode: "url" as const, | |
| 93 | − message: url.message, | |
| 94 | − url: url.url, | |
| 95 | − elicitationId: url.elicitationId, | |
| 96 | − })), | |
| 97 | − Match.tag("FormElicitation", (form) => ({ | |
| 98 | − message: form.message, | |
| 99 | − requestedSchema: | |
| 100 | − Object.keys(form.requestedSchema).length === 0 | |
| 101 | − ? { type: "object" as const, properties: {} } | |
| 102 | − : form.requestedSchema, | |
| 103 | − })), | |
| 104 | − Match.exhaustive, | |
| 105 | − ); | |
| 64 | +type McpServerRequestContext = McpRequestJoinKeys & { | |
| 65 | + readonly serverContext: ServerContext; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +/** Configuration required to build an Executor MCP server. */ | |
| 69 | +export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> = | |
| 70 | + ExecutorMcpToolConfig<E> & { | |
| 71 | + /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */ | |
| 72 | + readonly appsEnabled: boolean; | |
| 73 | + /** | |
| 74 | + * Register a connection-lifetime server whose capability-dependent behavior | |
| 75 | + * follows the live initialize-negotiated state. Omitted for stateless modern | |
| 76 | + * request factories, which keep using {@link appsEnabled} as fixed policy. | |
| 77 | + */ | |
| 78 | + readonly sessionful?: boolean; | |
| 79 | + /** HMAC key used to sign opaque native-elicitation continuation state. */ | |
| 80 | + readonly requestStateSigningKey: Uint8Array | string; | |
| 81 | + /** | |
| 82 | + * Stable identifier of the authenticated principal this server instance | |
| 83 | + * was built for (org/user/subject). Bound into the signed continuation | |
| 84 | + * state so a `requestState` minted for one principal is rejected when | |
| 85 | + * echoed by another — the spec's user-binding MUST for state that | |
| 86 | + * influences authorization. Single-user hosts pass a constant. | |
| 87 | + */ | |
| 88 | + readonly requestStatePrincipal: string; | |
| 89 | + /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */ | |
| 90 | + readonly requestStateTtlSeconds?: number; | |
| 91 | + }; | |
| 92 | + | |
| 93 | +/** Decide whether client capabilities advertise support for MCP Apps HTML. */ | |
| 94 | +export const appsEnabledForClientCapabilities = ( | |
| 95 | + clientCapabilities: McpAppsClientCapabilities | null | undefined, | |
| 96 | +): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); | |
| 97 | + | |
| 98 | +/** Bind modern continuation state to the ownership identity used by MCP hosts. */ | |
| 99 | +export const mcpRequestStatePrincipal = (principal: { | |
| 100 | + readonly accountId: string; | |
| 101 | + readonly organizationId: string; | |
| 102 | +}): string => `${principal.accountId}\u0000${principal.organizationId}`; | |
| 106 | 103 | |
| 107 | −const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => { | |
| 108 | − const capabilities = server.server.getClientCapabilities(); | |
| 109 | − if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false }; | |
| 110 | − const elicitation = capabilities.elicitation as Record<string, unknown>; | |
| 111 | − return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) }; | |
| 104 | +const requestStateBinding = (method: string, principal: string): string => | |
| 105 | + `${method}\u0000${principal}`; | |
| 106 | + | |
| 107 | +/** Route-level failure verifying untrusted modern continuation state. */ | |
| 108 | +export class McpRequestStateVerificationError extends Data.TaggedError( | |
| 109 | + "McpRequestStateVerificationError", | |
| 110 | +)<{ readonly cause: unknown }> {} | |
| 111 | + | |
| 112 | +/** | |
| 113 | + * Verify and parse a modern continuation before a stateless worker uses its | |
| 114 | + * execution id for Durable Object routing. | |
| 115 | + */ | |
| 116 | +export const verifyNativeRequestState = (input: { | |
| 117 | + readonly state: string; | |
| 118 | + readonly method: string; | |
| 119 | + readonly requestStateSigningKey: Uint8Array | string; | |
| 120 | + readonly requestStatePrincipal: string; | |
| 121 | +}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => { | |
| 122 | + const codec = createRequestStateCodec<NativeRequestState>({ | |
| 123 | + key: input.requestStateSigningKey, | |
| 124 | + bind: () => requestStateBinding(input.method, input.requestStatePrincipal), | |
| 125 | + }); | |
| 126 | + return Effect.tryPromise({ | |
| 127 | + // The route-level verifier has no handler context. Its codec binding is a | |
| 128 | + // closed value derived from the already-parsed method and principal, so the | |
| 129 | + // SDK callback never observes this inert placeholder. | |
| 130 | + try: async () => { | |
| 131 | + const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]); | |
| 132 | + return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); | |
| 133 | + }, | |
| 134 | + catch: (cause) => new McpRequestStateVerificationError({ cause }), | |
| 135 | + }); | |
| 112 | 136 | }; |
| 113 | 137 | |
| 114 | −const formatBoundaryError = ( | |
| 115 | − error: unknown, | |
| 116 | −): { name?: string; message: string; stack?: string } => { | |
| 117 | − // oxlint-disable-next-line executor/no-instanceof-error -- boundary: SDK Promise rejection supplies unknown JS errors for debug logging only | |
| 118 | − if (error instanceof Error) { | |
| 119 | − // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed native Error detail is confined to opt-in debug logging | |
| 120 | − return { name: error.name, message: error.message, stack: error.stack }; | |
| 138 | +const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 139 | + typeof value === "object" && value !== null && !Array.isArray(value); | |
| 140 | + | |
| 141 | +const appsClientCapabilitiesFromUnknown = ( | |
| 142 | + capabilities: unknown, | |
| 143 | +): McpAppsClientCapabilities | null => { | |
| 144 | + if (!isRecord(capabilities)) return null; | |
| 145 | + const extensions = capabilities.extensions; | |
| 146 | + if (!isRecord(extensions)) return null; | |
| 147 | + const ui = extensions[EXTENSION_ID]; | |
| 148 | + if (!isRecord(ui)) return null; | |
| 149 | + const mimeTypes = ui.mimeTypes; | |
| 150 | + if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } }; | |
| 151 | + if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) { | |
| 152 | + return null; | |
| 121 | 153 | } |
| 122 | − // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values | |
| 123 | − return { message: String(error) }; | |
| 154 | + return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; | |
| 124 | 155 | }; |
| 125 | 156 | |
| 126 | −const makeMcpElicitationHandler = | |
| 127 | − ( | |
| 128 | − server: McpServer, | |
| 129 | − relatedRequestId: string | number, | |
| 130 | − debugLog: (event: string, data: Record<string, unknown>) => void, | |
| 131 | − ): ElicitationHandler => | |
| 132 | − (context: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => { | |
| 133 | − const { url: supportsUrl } = getElicitationSupport(server); | |
| 134 | − const params = Match.value(context.request).pipe( | |
| 135 | − Match.tag( | |
| 136 | − "UrlElicitation", | |
| 137 | − (request): ElicitInputParams => | |
| 138 | − supportsUrl | |
| 139 | − ? elicitationRequestToParams(request) | |
| 140 | − : { | |
| 141 | − message: `${request.message}\n\nPlease visit this URL:\n${request.url}\n\nClick accept once you have completed the flow.`, | |
| 142 | − requestedSchema: { type: "object" as const, properties: {} }, | |
| 143 | − }, | |
| 144 | − ), | |
| 145 | − Match.tag( | |
| 146 | − "FormElicitation", | |
| 147 | − (request): ElicitInputParams => elicitationRequestToParams(request), | |
| 148 | − ), | |
| 149 | − Match.exhaustive, | |
| 150 | − ); | |
| 157 | +const elicitationSupportFromUnknown = ( | |
| 158 | + capabilities: unknown, | |
| 159 | +): { readonly form: boolean; readonly url: boolean } => { | |
| 160 | + if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) { | |
| 161 | + return { form: false, url: false }; | |
| 162 | + } | |
| 163 | + const elicitation = capabilities.elicitation; | |
| 164 | + const hasExplicitModes = "form" in elicitation || "url" in elicitation; | |
| 165 | + return { | |
| 166 | + form: hasExplicitModes ? Boolean(elicitation.form) : true, | |
| 167 | + url: Boolean(elicitation.url), | |
| 168 | + }; | |
| 169 | +}; | |
| 151 | 170 | |
| 152 | − return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => { | |
| 153 | − debugLog("elicitation.request", { | |
| 154 | − requestTag: elicitationRequestTag(context.request), | |
| 155 | − supportsUrl, | |
| 156 | − message: context.request.message, | |
| 157 | − hasRequestedSchema: requestedSchemaIsNonEmpty(context.request), | |
| 158 | − url: elicitationRequestUrl(context.request), | |
| 159 | − clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 160 | − }); | |
| 171 | +/** Parse the MCP Apps capability subset from an already-decoded modern body. */ | |
| 172 | +export const clientCapabilitiesFromRequestBody = ( | |
| 173 | + body: unknown, | |
| 174 | +): McpAppsClientCapabilities | null => { | |
| 175 | + if (!isRecord(body)) return null; | |
| 176 | + const params = body.params; | |
| 177 | + if (!isRecord(params)) return null; | |
| 178 | + const metadata = params._meta; | |
| 179 | + if (!isRecord(metadata)) return null; | |
| 180 | + const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY]; | |
| 181 | + return appsClientCapabilitiesFromUnknown(capabilities); | |
| 182 | +}; | |
| 161 | 183 | |
| 162 | − const response = await server.server.elicitInput( | |
| 163 | − params as Parameters<typeof server.server.elicitInput>[0], | |
| 164 | − { relatedRequestId }, | |
| 165 | − ); | |
| 166 | − debugLog("elicitation.response", { | |
| 167 | − requestTag: elicitationRequestTag(context.request), | |
| 168 | − action: response.action, | |
| 169 | − hasContent: | |
| 170 | − typeof response.content === "object" && | |
| 171 | − response.content !== null && | |
| 172 | − Object.keys(response.content).length > 0, | |
| 173 | − }); | |
| 174 | − return { | |
| 175 | − action: response.action as typeof ElicitationResponse.Type.action, | |
| 176 | − content: response.content, | |
| 177 | − }; | |
| 178 | − }).pipe( | |
| 179 | − Effect.tapDefect((defect) => | |
| 180 | − Effect.sync(() => { | |
| 181 | − debugLog("elicitation.error", { | |
| 182 | − requestTag: elicitationRequestTag(context.request), | |
| 183 | − error: formatBoundaryError(defect), | |
| 184 | − clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 185 | − }); | |
| 186 | − }), | |
| 187 | − ), | |
| 188 | − Effect.catchDefect((cause) => | |
| 189 | − // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ElicitationHandler has no error channel, so retain a classified defect for the MCP result boundary | |
| 190 | − Effect.die(new McpNativeElicitationTransportError({ cause })), | |
| 191 | − ), | |
| 192 | − ); | |
| 193 | − }; | |
| 184 | +/** Parse a cloned HTTP request body without consuming the request itself. */ | |
| 185 | +export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> => | |
| 186 | + Effect.tryPromise({ | |
| 187 | + try: () => request.clone().json(), | |
| 188 | + catch: () => null, | |
| 189 | + }).pipe( | |
| 190 | + Effect.match({ | |
| 191 | + onFailure: () => null, | |
| 192 | + onSuccess: (body) => body, | |
| 193 | + }), | |
| 194 | + ); | |
| 195 | + | |
| 196 | +/** | |
| 197 | + * Parse the MCP Apps capability subset from a modern request's `_meta` | |
| 198 | + * envelope without consuming the request body used by the SDK handler. | |
| 199 | + */ | |
| 200 | +export const clientCapabilitiesFromRequest = ( | |
| 201 | + request: Request, | |
| 202 | +): Effect.Effect<McpAppsClientCapabilities | null> => | |
| 203 | + requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody)); | |
| 194 | 204 | |
| 195 | −const requestJoinKeys = (extra: { | |
| 196 | − readonly requestId: string | number; | |
| 197 | − readonly sessionId?: string; | |
| 198 | −}): McpRequestJoinKeys => ({ | |
| 199 | − requestId: extra.requestId, | |
| 200 | − ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }), | |
| 205 | +const requestJoinKeys = (context: ServerContext): McpServerRequestContext => ({ | |
| 206 | + requestId: context.mcpReq.id, | |
| 207 | + ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), | |
| 208 | + serverContext: context, | |
| 201 | 209 | }); |
| 202 | 210 | |
| 203 | −const v1ToolResult = (result: McpHandlerResult): McpToolResult => | |
| 204 | − "resultType" in result | |
| 205 | − ? { | |
| 206 | − content: [{ type: "text", text: "Input-required results are unavailable on SDK v1." }], | |
| 207 | − isError: true, | |
| 208 | − } | |
| 209 | − : result; | |
| 211 | +const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => { | |
| 212 | + const ui = metadata.ui; | |
| 213 | + if (!isRecord(ui)) return undefined; | |
| 214 | + const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined; | |
| 215 | + const visibility = Array.isArray(ui.visibility) | |
| 216 | + ? ui.visibility.filter( | |
| 217 | + (value): value is "model" | "app" => value === "model" || value === "app", | |
| 218 | + ) | |
| 219 | + : undefined; | |
| 220 | + return { | |
| 221 | + ...(resourceUri === undefined ? {} : { resourceUri }), | |
| 222 | + ...(visibility === undefined ? {} : { visibility }), | |
| 223 | + }; | |
| 224 | +}; | |
| 210 | 225 | |
| 211 | −const createV1Assembly = <E extends Cause.YieldableError>( | |
| 226 | +const normalizedAppMetadata = (metadata: Record<string, unknown>) => { | |
| 227 | + const ui = appToolMeta(metadata); | |
| 228 | + const legacyResourceUri = metadata[RESOURCE_URI_META_KEY]; | |
| 229 | + return { | |
| 230 | + ...metadata, | |
| 231 | + ...(ui === undefined ? {} : { ui }), | |
| 232 | + ...(typeof legacyResourceUri === "string" | |
| 233 | + ? { [RESOURCE_URI_META_KEY]: legacyResourceUri } | |
| 234 | + : {}), | |
| 235 | + }; | |
| 236 | +}; | |
| 237 | + | |
| 238 | +const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => { | |
| 239 | + const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata; | |
| 240 | + return rest; | |
| 241 | +}; | |
| 242 | + | |
| 243 | +const visibilityIncludes = ( | |
| 244 | + metadata: Record<string, unknown>, | |
| 245 | + visibility: "model" | "app", | |
| 246 | +): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true; | |
| 247 | + | |
| 248 | +const toolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result; | |
| 249 | + | |
| 250 | +const elicitationInputRequest = (request: ElicitationRequest) => | |
| 251 | + Match.value(request).pipe( | |
| 252 | + Match.tag("FormElicitation", (form) => | |
| 253 | + inputRequired.elicit({ | |
| 254 | + message: form.message, | |
| 255 | + requestedSchema: | |
| 256 | + Object.keys(form.requestedSchema).length === 0 | |
| 257 | + ? fromJsonSchema({ type: "object" as const, properties: {} }) | |
| 258 | + : fromJsonSchema(form.requestedSchema), | |
| 259 | + }), | |
| 260 | + ), | |
| 261 | + Match.tag("UrlElicitation", (url) => | |
| 262 | + inputRequired.elicitUrl({ message: url.message, url: url.url }), | |
| 263 | + ), | |
| 264 | + Match.exhaustive, | |
| 265 | + ); | |
| 266 | + | |
| 267 | +const missingNativeExecution = (executionId: string): McpToolResult => ({ | |
| 268 | + content: [ | |
| 269 | + { | |
| 270 | + type: "text", | |
| 271 | + text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`, | |
| 272 | + }, | |
| 273 | + ], | |
| 274 | + structuredContent: { | |
| 275 | + status: "execution_not_found", | |
| 276 | + executionId, | |
| 277 | + }, | |
| 278 | + isError: true, | |
| 279 | +}); | |
| 280 | + | |
| 281 | +const createMcpAssembly = <E extends Cause.YieldableError>( | |
| 212 | 282 | config: ExecutorMcpServerConfig<E>, |
| 213 | −): ExecutorMcpAssembly<McpServer, McpRequestJoinKeys> => { | |
| 283 | +): ExecutorMcpAssembly<McpServer, McpServerRequestContext> => { | |
| 284 | + const sessionful = config.sessionful ?? false; | |
| 285 | + const initialAppsEnabled = sessionful | |
| 286 | + ? (config.restoredAppsEnabled ?? config.appsEnabled) | |
| 287 | + : config.appsEnabled; | |
| 288 | + const requestStateCodec = createRequestStateCodec<NativeRequestState>({ | |
| 289 | + key: config.requestStateSigningKey, | |
| 290 | + ...(config.requestStateTtlSeconds === undefined | |
| 291 | + ? {} | |
| 292 | + : { ttlSeconds: config.requestStateTtlSeconds }), | |
| 293 | + bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal), | |
| 294 | + }); | |
| 295 | + const verifyRequestState = async (state: string, context: ServerContext) => { | |
| 296 | + const decoded = await requestStateCodec.verify(state, context); | |
| 297 | + return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); | |
| 298 | + }; | |
| 214 | 299 | const server = new McpServer( |
| 215 | 300 | { name: "executor", version: "1.0.0" }, |
| 216 | 301 | { |
| 217 | 302 | capabilities: { resources: {}, tools: {} }, |
| 218 | − jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), | |
| 303 | + requestState: { verify: verifyRequestState }, | |
| 219 | 304 | }, |
| 220 | 305 | ); |
| 221 | 306 | |
| 222 | − const registerTool: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerTool"] = ( | |
| 307 | + const registerTool: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerTool"] = ( | |
| 223 | 308 | name, |
| 224 | 309 | toolConfig, |
| 225 | 310 | callback, |
| @@ -228,69 +313,151 @@ const createV1Assembly = <E extends Cause.YieldableError>( · 2 unmodified lines | ||
| 228 | 313 | return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( |
| 229 | 314 | name, |
| 230 | 315 | { ...toolConfig, inputSchema }, |
| 231 | − async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))), | |
| 316 | + async (args, context) => toolResult(await callback(args, requestJoinKeys(context))), | |
| 232 | 317 | ); |
| 233 | 318 | }; |
| 234 | 319 | |
| 235 | − const registerApp: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerAppTool"] = ( | |
| 320 | + const registerApp: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerAppTool"] = ( | |
| 236 | 321 | name, |
| 237 | 322 | toolConfig, |
| 238 | 323 | callback, |
| 239 | 324 | ) => { |
| 240 | 325 | const inputSchema = z.object(toolConfig.inputSchema); |
| 241 | − return registerAppTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( | |
| 326 | + const metadata = normalizedAppMetadata(toolConfig._meta); | |
| 327 | + if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) { | |
| 328 | + const plainMetadata = withoutAppMetadata(metadata); | |
| 329 | + return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( | |
| 330 | + name, | |
| 331 | + { | |
| 332 | + ...toolConfig, | |
| 333 | + inputSchema, | |
| 334 | + ...(Object.keys(plainMetadata).length === 0 | |
| 335 | + ? { _meta: undefined } | |
| 336 | + : { _meta: plainMetadata }), | |
| 337 | + }, | |
| 338 | + async (args, context) => toolResult(await callback(args, requestJoinKeys(context))), | |
| 339 | + ); | |
| 340 | + } | |
| 341 | + | |
| 342 | + return registerAppTool<typeof inputSchema, z.ZodObject<z.ZodRawShape>>( | |
| 242 | 343 | server, |
| 243 | 344 | name, |
| 244 | − { ...toolConfig, inputSchema }, | |
| 245 | − async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))), | |
| 345 | + { ...toolConfig, inputSchema, _meta: metadata }, | |
| 346 | + async (args, context) => toolResult(await callback(args, requestJoinKeys(context))), | |
| 246 | 347 | ); |
| 247 | 348 | }; |
| 248 | 349 | |
| 350 | + const nativeInputRequired = async <NativeE extends Cause.YieldableError>( | |
| 351 | + services: NativeExecutionServices<NativeE, McpServerRequestContext>, | |
| 352 | + execution: Parameters< | |
| 353 | + NativeExecutionServices<NativeE, McpServerRequestContext>["executionPaused"] | |
| 354 | + >[0], | |
| 355 | + ): Promise<InputRequiredResult> => { | |
| 356 | + const requestState = await requestStateCodec.mint( | |
| 357 | + { executionId: execution.id }, | |
| 358 | + services.requestContext.serverContext, | |
| 359 | + ); | |
| 360 | + return inputRequired({ | |
| 361 | + inputRequests: { | |
| 362 | + [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest( | |
| 363 | + execution.elicitationContext.request, | |
| 364 | + ), | |
| 365 | + }, | |
| 366 | + requestState, | |
| 367 | + }); | |
| 368 | + }; | |
| 369 | + | |
| 249 | 370 | return { |
| 250 | 371 | server, |
| 251 | − era: "v1", | |
| 252 | − initialAppsEnabled: config.restoredAppsEnabled ?? false, | |
| 253 | − getClientCapabilities: () => server.server.getClientCapabilities() ?? null, | |
| 254 | − getElicitationSupport: () => getElicitationSupport(server), | |
| 372 | + initialAppsEnabled, | |
| 373 | + getClientCapabilities: () => | |
| 374 | + sessionful ? (server.server.getClientCapabilities() ?? null) : null, | |
| 375 | + getElicitationSupport: () => | |
| 376 | + sessionful | |
| 377 | + ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) | |
| 378 | + : { form: true, url: true }, | |
| 255 | 379 | getUiCapability: () => |
| 256 | − getUiCapability( | |
| 257 | − server.server.getClientCapabilities() as | |
| 258 | − | (ClientCapabilities & { extensions?: Record<string, unknown> }) | |
| 259 | − | null, | |
| 260 | − ), | |
| 380 | + sessionful | |
| 381 | + ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities())) | |
| 382 | + : config.appsEnabled | |
| 383 | + ? { mimeTypes: [RESOURCE_MIME_TYPE] } | |
| 384 | + : undefined, | |
| 261 | 385 | onInitialized: (callback) => { |
| 262 | − server.server.oninitialized = callback; | |
| 386 | + if (sessionful) server.server.oninitialized = callback; | |
| 263 | 387 | }, |
| 264 | 388 | registerTool, |
| 265 | 389 | registerAppTool: registerApp, |
| 266 | 390 | registerAppResource: (name, uri, resourceConfig, callback) => { |
| 391 | + if (!sessionful && !config.appsEnabled) return; | |
| 267 | 392 | registerAppResource(server, name, uri, resourceConfig, async () => { |
| 268 | 393 | const result = await callback(); |
| 269 | 394 | return { contents: [...result.contents] }; |
| 270 | 395 | }); |
| 271 | 396 | }, |
| 272 | 397 | executeNative: <NativeE extends Cause.YieldableError>( |
| 273 | − services: NativeExecutionServices<NativeE, McpRequestJoinKeys>, | |
| 398 | + services: NativeExecutionServices<NativeE, McpServerRequestContext>, | |
| 274 | 399 | ) => |
| 275 | − services.engine | |
| 276 | − .execute(services.code, { | |
| 277 | − onElicitation: makeMcpElicitationHandler( | |
| 278 | − server, | |
| 279 | − services.requestContext.requestId, | |
| 280 | − services.debugLog, | |
| 281 | − ), | |
| 282 | − }) | |
| 283 | − .pipe(Effect.map(services.complete)), | |
| 400 | + Effect.gen(function* () { | |
| 401 | + const decodedState = decodeNativeRequestState( | |
| 402 | + services.requestContext.serverContext.mcpReq.requestState<unknown>(), | |
| 403 | + ); | |
| 404 | + | |
| 405 | + if (Option.isSome(decodedState)) { | |
| 406 | + const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId); | |
| 407 | + if (!paused) return missingNativeExecution(decodedState.value.executionId); | |
| 408 | + const response = inputResponse( | |
| 409 | + services.requestContext.serverContext.mcpReq.inputResponses, | |
| 410 | + NATIVE_ELICITATION_RESPONSE_KEY, | |
| 411 | + ); | |
| 412 | + if (response.kind === "elicit") { | |
| 413 | + const content = Match.value(paused.elicitationContext.request).pipe( | |
| 414 | + Match.tag("UrlElicitation", () => response.content), | |
| 415 | + Match.tag("FormElicitation", (form) => | |
| 416 | + response.action === "accept" | |
| 417 | + ? acceptedContent( | |
| 418 | + services.requestContext.serverContext.mcpReq.inputResponses, | |
| 419 | + NATIVE_ELICITATION_RESPONSE_KEY, | |
| 420 | + fromJsonSchema<Record<string, unknown>>( | |
| 421 | + Object.keys(form.requestedSchema).length === 0 | |
| 422 | + ? { type: "object", properties: {} } | |
| 423 | + : form.requestedSchema, | |
| 424 | + ), | |
| 425 | + ) | |
| 426 | + : response.content, | |
| 427 | + ), | |
| 428 | + Match.exhaustive, | |
| 429 | + ); | |
| 430 | + if (response.action === "accept" && content === undefined) { | |
| 431 | + return yield* Effect.promise(() => nativeInputRequired(services, paused)); | |
| 432 | + } | |
| 433 | + const outcome = yield* services.resume(decodedState.value.executionId, { | |
| 434 | + action: response.action, | |
| 435 | + content, | |
| 436 | + }); | |
| 437 | + if (!outcome) return missingNativeExecution(decodedState.value.executionId); | |
| 438 | + if (outcome.status === "completed") return services.complete(outcome.result); | |
| 439 | + yield* services.executionPaused(outcome.execution); | |
| 440 | + return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); | |
| 441 | + } | |
| 442 | + | |
| 443 | + return yield* Effect.promise(() => nativeInputRequired(services, paused)); | |
| 444 | + } | |
| 445 | + | |
| 446 | + const outcome = yield* services.engine.executeWithPause(services.code); | |
| 447 | + if (outcome.status === "completed") return services.complete(outcome.result); | |
| 448 | + yield* services.executionPaused(outcome.execution); | |
| 449 | + return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); | |
| 450 | + }), | |
| 284 | 451 | }; |
| 285 | 452 | }; |
| 286 | 453 | |
| 287 | 454 | /** |
| 288 | − * Build the legacy SDK v1 Executor MCP tool server. | |
| 455 | + * Build one Executor MCP server. | |
| 289 | 456 | * |
| 290 | − * Its public signature and wire behavior remain unchanged; SDK-specific | |
| 291 | − * construction and native elicitation are confined to this assembly. | |
| 457 | + * Stateless hosts must reuse the signing key across every request that can | |
| 458 | + * participate in the same native-elicitation continuation flow. Sessionful | |
| 459 | + * hosts keep one instance connected and may use a connection-lifetime key. | |
| 292 | 460 | */ |
| 293 | −export const createExecutorMcpServer = <E extends Cause.YieldableError>( | |
| 461 | +export const buildMcpServer = <E extends Cause.YieldableError>( | |
| 294 | 462 | config: ExecutorMcpServerConfig<E>, |
| 295 | −): Effect.Effect<McpServer> => | |
| 296 | − createExecutorMcpServerAssembly(config, () => createV1Assembly(config)); | |
| 463 | +): Effect.Effect<McpServer> => buildExecutorMcpTools(config, () => createMcpAssembly(config)); | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect"; |
| 2 | 2 | import * as http from "node:http"; |
| 3 | +// Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients. | |
| 3 | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 4 | 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 5 | 6 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; |