Unified · Split
| @@ -6,7 +6,7 @@ · 5 unmodified lines | |||
| 6 | // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug | 6 | // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug |
| 7 | // here; see db/db.schema.test.ts) | 7 | // here; see db/db.schema.test.ts) |
| 8 | // - `createExecutionEngine` with an in-process code executor | 8 | // - `createExecutionEngine` with an in-process code executor |
| 9 | // - `createExecutorMcpServer` for the MCP request surface | 9 | // - `buildMcpServer` for the MCP request surface |
| 10 | // - Real `@modelcontextprotocol/sdk` Client → server round-trips | 10 | // - Real `@modelcontextprotocol/sdk` Client → server round-trips |
| 11 | // | 11 | // |
| 12 | // This test replicates the DO's init path (minus the WorkerTransport and | 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 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | 22 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 23 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | 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 | import { createExecutionEngine } from "@executor-js/execution"; | 26 | import { createExecutionEngine } from "@executor-js/execution"; |
| 27 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 27 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 28 | import { collectTables } from "@executor-js/api/server"; | 28 | import { collectTables } from "@executor-js/api/server"; |
| @@ -138,8 +138,12 @@ const openSession = ( · 109 unmodified lines | |||
| 138 | Effect.gen(function* () { | 138 | Effect.gen(function* () { |
| 139 | const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); | 139 | const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); |
| 140 | const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); | 140 | const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); |
| 141 | const mcpServer = yield* createExecutorMcpServer({ | 141 | const mcpServer = yield* buildMcpServer({ |
| 142 | engine, | 142 | engine, |
| 143 | appsEnabled: false, | ||
| 144 | requestStateSigningKey: new Uint8Array(32).fill(23), | ||
| 145 | requestStatePrincipal: `cloud-mcp-test:${organizationId}`, | ||
| 146 | sessionful: true, | ||
| 143 | elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined, | 147 | elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined, |
| 144 | }); | 148 | }); |
| 145 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | 149 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type AuthOutcome, | 9 | type AuthOutcome, |
| 10 | type McpResource, | 10 | type McpResource, |
| 11 | } from "@executor-js/host-mcp"; | 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 | import { | 13 | import { |
| 14 | currentPropagationHeaders, | 14 | currentPropagationHeaders, |
| 15 | readArtifactsEnabled, | 15 | readArtifactsEnabled, |
| @@ -23,10 +23,11 @@ import postgres, { type Sql } from "postgres"; · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | import { | 24 | import { |
| 25 | PAUSED_APPROVAL_TIMEOUT_MS, | 25 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 26 | buildMcpServer, | ||
| 27 | mcpRequestStatePrincipal, | ||
| 26 | type PausedExecutionHooks, | 28 | type PausedExecutionHooks, |
| 27 | type ResumeFallbackOutcome, | 29 | type ResumeFallbackOutcome, |
| 28 | } from "@executor-js/host-mcp/tool-server"; | 30 | } from "@executor-js/host-mcp/tool-server"; |
| 29 | import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; | ||
| 30 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | 31 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; |
| 31 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 32 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 32 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 33 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| @@ -232,7 +233,7 @@ const makeCloudModernRuntime = ( · 199 unmodified lines | |||
| 232 | ): BuiltModernMcpRuntime => ({ | 233 | ): BuiltModernMcpRuntime => ({ |
| 233 | engine: runtime.engine, | 234 | engine: runtime.engine, |
| 234 | buildServer: (options) => | 235 | buildServer: (options) => |
| 235 | buildMcpServerV2({ | 236 | buildMcpServer({ |
| 236 | engine: runtime.engine, | 237 | engine: runtime.engine, |
| 237 | description: runtime.description, | 238 | description: runtime.description, |
| 238 | artifacts: runtime.executor.artifacts, | 239 | artifacts: runtime.executor.artifacts, |
| @@ -270,7 +271,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 31 unmodified lines | |||
| 270 | return server; | 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 | export const makeCloudModernMcpServerBuilder = ( | 275 | export const makeCloudModernMcpServerBuilder = ( |
| 275 | session: McpSessionInit, | 276 | session: McpSessionInit, |
| 276 | ): McpModernServerBuilder["Service"] => ({ | 277 | ): McpModernServerBuilder["Service"] => ({ |
| @@ -366,7 +367,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 89 unmodified lines | |||
| 366 | parentSpan: () => self.currentParentSpan(), | 367 | parentSpan: () => self.currentParentSpan(), |
| 367 | }); | 368 | }); |
| 368 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; | 369 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 369 | const mcpServer = yield* buildMcpServerV2({ | 370 | const mcpServer = yield* buildMcpServer({ |
| 370 | engine, | 371 | engine, |
| 371 | description, | 372 | description, |
| 372 | artifacts: executor.artifacts, | 373 | artifacts: executor.artifacts, |
| @@ -411,7 +412,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 38 unmodified lines | |||
| 411 | }), | 412 | }), |
| 412 | } | 413 | } |
| 413 | : { mode: sessionElicitationMode }, | 414 | : { mode: sessionElicitationMode }, |
| 414 | }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServerV2")); | 415 | }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer")); |
| 415 | return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; | 416 | return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; |
| 416 | }).pipe( | 417 | }).pipe( |
| 417 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), | 418 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | type AuthOutcome, | 7 | type AuthOutcome, |
| 8 | type Principal, | 8 | type Principal, |
| 9 | } from "@executor-js/host-mcp"; | 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 | import { | 11 | import { |
| 12 | currentPropagationHeaders, | 12 | currentPropagationHeaders, |
| 13 | readArtifactsEnabled, | 13 | readArtifactsEnabled, |
| @@ -2,10 +2,11 @@ import { Data, Effect } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | PAUSED_APPROVAL_TIMEOUT_MS, | 4 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 5 | buildMcpServer, | ||
| 6 | mcpRequestStatePrincipal, | ||
| 5 | type PausedExecutionHooks, | 7 | type PausedExecutionHooks, |
| 6 | type ResumeFallbackOutcome, | 8 | type ResumeFallbackOutcome, |
| 7 | } from "@executor-js/host-mcp/tool-server"; | 9 | } from "@executor-js/host-mcp/tool-server"; |
| 8 | import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; | ||
| 9 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | 10 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; |
| 10 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 11 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 11 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 12 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| @@ -101,7 +102,7 @@ const makeCloudflareModernRuntime = ( · 89 unmodified lines | |||
| 101 | return { | 102 | return { |
| 102 | engine: runtime.engine, | 103 | engine: runtime.engine, |
| 103 | buildServer: (options) => | 104 | buildServer: (options) => |
| 104 | buildMcpServerV2({ | 105 | buildMcpServer({ |
| 105 | engine: runtime.engine, | 106 | engine: runtime.engine, |
| 106 | description: runtime.description, | 107 | description: runtime.description, |
| 107 | artifacts: runtime.executor.artifacts, | 108 | artifacts: runtime.executor.artifacts, |
| @@ -137,7 +138,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 29 unmodified lines | |||
| 137 | return server; | 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 | export const makeCloudflareModernMcpServerBuilder = ( | 142 | export const makeCloudflareModernMcpServerBuilder = ( |
| 142 | env: CloudflareEnv, | 143 | env: CloudflareEnv, |
| 143 | config: CloudflareConfig, | 144 | config: CloudflareConfig, |
| @@ -268,7 +269,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 124 unmodified lines | |||
| 268 | // only offered when this deployment knows its own public URL. Without one | 269 | // only offered when this deployment knows its own public URL. Without one |
| 269 | // the artifact is still saved, and the tool says so. | 270 | // the artifact is still saved, and the tool says so. |
| 270 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; | 271 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; |
| 271 | const mcpServer = yield* buildMcpServerV2({ | 272 | const mcpServer = yield* buildMcpServer({ |
| 272 | engine, | 273 | engine, |
| 273 | description, | 274 | description, |
| 274 | artifacts: executor.artifacts, | 275 | artifacts: executor.artifacts, |
| @@ -44,7 +44,7 @@ export { · 43 unmodified lines | |||
| 44 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns | 44 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns |
| 45 | // dispatch (create + forward + ownership) and builds its | 45 | // dispatch (create + forward + ownership) and builds its |
| 46 | // engine internally over the shared SelfHostDb. | 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 | // the same scoped execution stack and tool config. | 48 | // the same scoped execution stack and tool config. |
| 49 | // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the | 49 | // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the |
| 50 | // host's console capture. | 50 | // host's console capture. |
| @@ -58,7 +58,7 @@ export interface SelfHostMcpSeams { · 7 unmodified lines | |||
| 58 | readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>; | 58 | readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>; |
| 59 | /** The in-process session store seam (dispatch + lifetime). */ | 59 | /** The in-process session store seam (dispatch + lifetime). */ |
| 60 | readonly sessions: Layer.Layer<McpSessionStore>; | 60 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 61 | /** Stateless SDK v2 server construction for modern requests. */ | 61 | /** Stateless MCP server construction for modern requests. */ |
| 62 | readonly modern: Layer.Layer<McpModernServerBuilder>; | 62 | readonly modern: Layer.Layer<McpModernServerBuilder>; |
| 63 | /** Route 500 defects through the host's console `ErrorCapture`. */ | 63 | /** Route 500 defects through the host's console `ErrorCapture`. */ |
| 64 | readonly reporter: Layer.Layer<McpErrorReporter>; | 64 | readonly reporter: Layer.Layer<McpErrorReporter>; |
| @@ -1,10 +1,6 @@ | |||
| 1 | import { Layer } from "effect"; | 1 | import { Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; |
| 4 | makeConsoleMcpErrorReporter, | ||
| 5 | makeMcpBuildServer, | ||
| 6 | makeMcpBuildServerV2, | ||
| 7 | } from "@executor-js/api/server"; | ||
| 8 | import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; | 4 | import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; |
| 9 | import { | 5 | import { |
| 10 | inMemoryMcpSessionsLayer, | 6 | inMemoryMcpSessionsLayer, |
| @@ -24,7 +20,7 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 13 unmodified lines | |||
| 24 | // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host | 20 | // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host |
| 25 | // supplies only its fully-provided execution-stack layer (QuickJS over the | 21 | // supplies only its fully-provided execution-stack layer (QuickJS over the |
| 26 | // long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the | 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 | import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; | 26 | import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; |
| @@ -55,12 +51,12 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines | |||
| 55 | { webBaseUrl }, | 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 | export const makeSelfHostMcpModernServerBuilder = ( | 55 | export const makeSelfHostMcpModernServerBuilder = ( |
| 60 | db: SelfHostDbHandle, | 56 | db: SelfHostDbHandle, |
| 61 | ): Layer.Layer<McpModernServerBuilder> => | 57 | ): Layer.Layer<McpModernServerBuilder> => |
| 62 | Layer.succeed(McpModernServerBuilder)({ | 58 | Layer.succeed(McpModernServerBuilder)({ |
| 63 | build: makeMcpBuildServerV2( | 59 | build: makeMcpBuildServer( |
| 64 | SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), | 60 | SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), |
| 65 | { | 61 | { |
| 66 | loadAppShellHtml: loadMcpAppsShellHtml, | 62 | loadAppShellHtml: loadMcpAppsShellHtml, |
| @@ -16,11 +16,11 @@ import { · 15 unmodified lines | |||
| 16 | } from "@executor-js/host-mcp"; | 16 | } from "@executor-js/host-mcp"; |
| 17 | import { | 17 | import { |
| 18 | appsEnabledForClientCapabilities, | 18 | appsEnabledForClientCapabilities, |
| 19 | buildMcpServerV2, | 19 | buildMcpServer, |
| 20 | clientCapabilitiesFromRequest, | 20 | clientCapabilitiesFromRequest, |
| 21 | requestBodyFromRequest, | 21 | requestBodyFromRequest, |
| 22 | type ExecutorMcpServerConfig, | 22 | type ExecutorMcpToolConfig, |
| 23 | } from "@executor-js/host-mcp/tool-server-v2"; | 23 | } from "@executor-js/host-mcp/tool-server"; |
| 24 | import { | 24 | import { |
| 25 | approvalUrlForRequest, | 25 | approvalUrlForRequest, |
| 26 | decodeResumeResponse, | 26 | decodeResumeResponse, |
| @@ -53,12 +53,12 @@ export type McpRequestHandler = { · 26 unmodified lines | |||
| 53 | }; | 53 | }; |
| 54 | 54 | ||
| 55 | export interface LocalMcpServerConfig { | 55 | export interface LocalMcpServerConfig { |
| 56 | readonly config: ExecutorMcpServerConfig; | 56 | readonly config: ExecutorMcpToolConfig; |
| 57 | readonly close?: () => Promise<void>; | 57 | readonly close?: () => Promise<void>; |
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | export interface LocalMcpRequestHandlerConfig { | 60 | export interface LocalMcpRequestHandlerConfig { |
| 61 | readonly defaultConfig: ExecutorMcpServerConfig; | 61 | readonly defaultConfig: ExecutorMcpToolConfig; |
| 62 | readonly createConfigForResource?: ( | 62 | readonly createConfigForResource?: ( |
| 63 | resource: McpResource, | 63 | resource: McpResource, |
| 64 | ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig; | 64 | ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig; |
| @@ -121,15 +121,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines | |||
| 121 | return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; | 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 | "engine" in config ? config.engine : null; | 125 | "engine" in config ? config.engine : null; |
| 126 | 126 | ||
| 127 | const normalizeHandlerConfig = ( | 127 | const normalizeHandlerConfig = ( |
| 128 | input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | 128 | input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, |
| 129 | ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); | 129 | ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); |
| 130 | 130 | ||
| 131 | export const createMcpRequestHandler = ( | 131 | export const createMcpRequestHandler = ( |
| 132 | input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | 132 | input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, |
| 133 | ): McpRequestHandler => { | 133 | ): McpRequestHandler => { |
| 134 | const handlerConfig = normalizeHandlerConfig(input); | 134 | const handlerConfig = normalizeHandlerConfig(input); |
| 135 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); | 135 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| @@ -193,7 +193,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines | |||
| 193 | Effect.gen(function* () { | 193 | Effect.gen(function* () { |
| 194 | const resourceConfig = yield* Effect.promise(() => configForResource(resource)); | 194 | const resourceConfig = yield* Effect.promise(() => configForResource(resource)); |
| 195 | const clientCapabilities = yield* clientCapabilitiesFromRequest(request); | 195 | const clientCapabilities = yield* clientCapabilitiesFromRequest(request); |
| 196 | const server = yield* buildMcpServerV2({ | 196 | const server = yield* buildMcpServer({ |
| 197 | ...resourceConfig.config, | 197 | ...resourceConfig.config, |
| 198 | artifactsEnabled: readArtifactsEnabled(request), | 198 | artifactsEnabled: readArtifactsEnabled(request), |
| 199 | appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), | 199 | appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), |
| @@ -277,7 +277,7 @@ export const createMcpRequestHandler = ( · 77 unmodified lines | |||
| 277 | const elicitationMode = readElicitationMode(request); | 277 | const elicitationMode = readElicitationMode(request); |
| 278 | resourceConfig = await configForResource(resource); | 278 | resourceConfig = await configForResource(resource); |
| 279 | created = await Effect.runPromise( | 279 | created = await Effect.runPromise( |
| 280 | buildMcpServerV2({ | 280 | buildMcpServer({ |
| 281 | ...resourceConfig.config, | 281 | ...resourceConfig.config, |
| 282 | browserApprovalStore: approvals.store, | 282 | browserApprovalStore: approvals.store, |
| 283 | artifactsEnabled: readArtifactsEnabled(request), | 283 | artifactsEnabled: readArtifactsEnabled(request), |
| @@ -360,13 +360,13 @@ export const createMcpRequestHandler = ( · 76 unmodified lines | |||
| 360 | // Stdio transport | 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 | startIntegrationsRefresh(); | 364 | startIntegrationsRefresh(); |
| 365 | 365 | ||
| 366 | const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); | 366 | const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); |
| 367 | const stdio = serveStdio(() => | 367 | const stdio = serveStdio(() => |
| 368 | Effect.runPromise( | 368 | Effect.runPromise( |
| 369 | buildMcpServerV2({ | 369 | buildMcpServer({ |
| 370 | ...config, | 370 | ...config, |
| 371 | appsEnabled: config.restoredAppsEnabled ?? false, | 371 | appsEnabled: config.restoredAppsEnabled ?? false, |
| 372 | requestStateSigningKey, | 372 | requestStateSigningKey, |
| @@ -701,12 +701,9 @@ · 700 unmodified lines | |||
| 701 | "name": "@executor-js/host-mcp", | 701 | "name": "@executor-js/host-mcp", |
| 702 | "version": "1.4.4", | 702 | "version": "1.4.4", |
| 703 | "dependencies": { | 703 | "dependencies": { |
| 704 | "@cfworker/json-schema": "^4.1.1", | ||
| 705 | "@executor-js/execution": "workspace:*", | 704 | "@executor-js/execution": "workspace:*", |
| 706 | "@executor-js/sdk": "workspace:*", | 705 | "@executor-js/sdk": "workspace:*", |
| 707 | "@modelcontextprotocol/core": "2.0.0", | 706 | "@modelcontextprotocol/core": "2.0.0", |
| 708 | "@modelcontextprotocol/ext-apps": "^1.7.4", | ||
| 709 | "@modelcontextprotocol/sdk": "^1.29.0", | ||
| 710 | "@modelcontextprotocol/server": "2.0.0", | 707 | "@modelcontextprotocol/server": "2.0.0", |
| 711 | "effect": "catalog:", | 708 | "effect": "catalog:", |
| 712 | "zod": "4.3.6", | 709 | "zod": "4.3.6", |
| @@ -714,6 +711,7 @@ · 1 unmodified line | |||
| 714 | "devDependencies": { | 711 | "devDependencies": { |
| 715 | "@effect/vitest": "catalog:", | 712 | "@effect/vitest": "catalog:", |
| 716 | "@modelcontextprotocol/client": "2.0.0", | 713 | "@modelcontextprotocol/client": "2.0.0", |
| 714 | "@modelcontextprotocol/sdk": "^1.29.0", | ||
| 717 | "@types/node": "catalog:", | 715 | "@types/node": "catalog:", |
| 718 | "bun-types": "catalog:", | 716 | "bun-types": "catalog:", |
| 719 | "vitest": "catalog:", | 717 | "vitest": "catalog:", |
| @@ -58,9 +58,8 @@ export { · 57 unmodified lines | |||
| 58 | } from "./server/execution-stack"; | 58 | } from "./server/execution-stack"; |
| 59 | export { | 59 | export { |
| 60 | makeMcpBuildServer, | 60 | makeMcpBuildServer, |
| 61 | makeMcpBuildServerV2, | ||
| 62 | makeConsoleMcpErrorReporter, | 61 | makeConsoleMcpErrorReporter, |
| 63 | type McpBuildServerV2, | 62 | type McpBuildServer, |
| 64 | type McpExecutionStackLayer, | 63 | type McpExecutionStackLayer, |
| 65 | } from "./server/mcp-build"; | 64 | } from "./server/mcp-build"; |
| 66 | // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author | 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 | * Owns the legacy serving-session lifecycle (in-process Map vs DO). | 148 | * Owns the legacy serving-session lifecycle (in-process Map vs DO). |
| 149 | */ | 149 | */ |
| 150 | readonly sessions: Layer.Layer<McpSessionStore>; | 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 | readonly modern: Layer.Layer<McpModernServerBuilder>; | 152 | readonly modern: Layer.Layer<McpModernServerBuilder>; |
| 153 | } | 153 | } |
| 154 | | { | 154 | | { |
| @@ -3,13 +3,15 @@ import { Effect, Layer } from "effect"; · 2 unmodified lines | |||
| 3 | import { | 3 | import { |
| 4 | McpErrorReporter, | 4 | McpErrorReporter, |
| 5 | type McpModernServerBuilder, | 5 | type McpModernServerBuilder, |
| 6 | type McpModernServerBuildOptions, | ||
| 6 | type Principal, | 7 | type Principal, |
| 7 | } from "@executor-js/host-mcp"; | 8 | } from "@executor-js/host-mcp"; |
| 8 | import { | 9 | import { |
| 9 | McpEngineBuildError, | 10 | McpEngineBuildError, |
| 10 | type McpBuildServer, | 11 | type McpBuildServer as McpSessionBuildServer, |
| 12 | type McpBuildServerOptions as McpSessionBuildOptions, | ||
| 11 | } from "@executor-js/host-mcp/in-memory-session-store"; | 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 | import { | 15 | import { |
| 14 | artifactUrlFor, | 16 | artifactUrlFor, |
| 15 | type ArtifactSmokeRenderResult, | 17 | type ArtifactSmokeRenderResult, |
| @@ -23,9 +25,8 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 7 unmodified lines | |||
| 23 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| 24 | // Shared in-process MCP host helpers. | 26 | // Shared in-process MCP host helpers. |
| 25 | // | 27 | // |
| 26 | // Neutral hosts build both sessionful legacy connections and stateless modern | 28 | // Neutral hosts build both sessionful legacy-wire connections and stateless |
| 27 | // requests from the same SDK v2 assembly over a scoped execution stack. The | 29 | // modern requests from the same assembly over a scoped execution stack. |
| 28 | // Cloudflare Durable Object path remains a separate v1-backed composition root. | ||
| 29 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 30 | 31 | ||
| 31 | /** The five execution-stack seams a host fully provides (no residual). */ | 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 | DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator | 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: | 49 | * Build the unified MCP server factory over a host's execution stack. |
| 38 | * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only | 50 | * Session callers receive the server plus its approval-owning engine; modern |
| 39 | * in the injected stack layer (libSQL vs D1, etc.). | 51 | * request callers receive the server directly. |
| 40 | */ | 52 | */ |
| 41 | export const makeMcpBuildServer = | 53 | export const makeMcpBuildServer = ( |
| 42 | (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer => | 54 | executionStack: McpExecutionStackLayer, |
| 43 | (principal: Principal, options) => { | 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 | const { resource, ...serverOptions } = options; | 66 | const { resource, ...serverOptions } = options; |
| 45 | return Effect.gen(function* () { | 67 | return Effect.gen(function* () { |
| 46 | const { engine, executor } = yield* makeExecutionStack( | 68 | const { engine, executor } = yield* makeExecutionStack( |
| @@ -50,20 +72,17 @@ export const makeMcpBuildServer = · 3 unmodified lines | |||
| 50 | { mcpResource: resource }, | 72 | { mcpResource: resource }, |
| 51 | ).pipe(Effect.withSpan("mcp.execution_stack.build")); | 73 | ).pipe(Effect.withSpan("mcp.execution_stack.build")); |
| 52 | // Read inside the provided boundary: `webBaseUrl` is a host seam, and | 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 | 75 | // hosts that cannot know their public URL at boot leave it unset. |
| 54 | // which case artifacts still persist but carry no deep link. | ||
| 55 | const hostConfig = yield* HostConfig; | 76 | const hostConfig = yield* HostConfig; |
| 56 | return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; | 77 | return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; |
| 57 | }).pipe( | 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 | principal.organizationSlug !== undefined | 79 | principal.organizationSlug !== undefined |
| 61 | ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) | 80 | ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) |
| 62 | : (effect) => effect, | 81 | : (effect) => effect, |
| 63 | Effect.provide(executionStack), | 82 | Effect.provide(executionStack), |
| 64 | Effect.mapError((cause) => new McpEngineBuildError({ cause })), | 83 | Effect.mapError((cause) => new McpEngineBuildError({ cause })), |
| 65 | Effect.flatMap(({ engine, executor, webBaseUrl }) => | 84 | Effect.flatMap(({ engine, executor, webBaseUrl }) => |
| 66 | buildMcpServerV2({ | 85 | buildMcpServer({ |
| 67 | engine, | 86 | engine, |
| 68 | artifacts: executor.artifacts, | 87 | artifacts: executor.artifacts, |
| 69 | connections: executor.connections, | 88 | connections: executor.connections, |
| @@ -74,68 +93,20 @@ export const makeMcpBuildServer = · 4 unmodified lines | |||
| 74 | ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact } | 93 | ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact } |
| 75 | : {}), | 94 | : {}), |
| 76 | ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}), | 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 | ...(webBaseUrl | 96 | ...(webBaseUrl |
| 82 | ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } | 97 | ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } |
| 83 | : {}), | 98 | : {}), |
| 84 | ...serverOptions, | 99 | ...serverOptions, |
| 85 | }).pipe( | 100 | }).pipe( |
| 86 | Effect.withSpan("mcp.server.create"), | 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. */ | 108 | return build; |
| 94 | export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"]; | 109 | }; |
| 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 | }; | ||
| 139 | 110 | ||
| 140 | /** Per-host (not per-session) MCP wiring. Kept separate from | 111 | /** Per-host (not per-session) MCP wiring. Kept separate from |
| 141 | * `McpBuildServerOptions`, which the session store fills in per request. */ | 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 | import type { ExecutionEngine } from "@executor-js/execution"; | 6 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 7 | import { defaultMcpResource } from "@executor-js/host-mcp"; | 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 | import { withVerifiedIdentityHeaders } from "./do-headers"; | 10 | import { withVerifiedIdentityHeaders } from "./do-headers"; |
| 11 | import { | 11 | import { |
| @@ -208,7 +208,7 @@ class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, { readonly en · 196 unmodified lines | |||
| 208 | 208 | ||
| 209 | protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect<BuiltMcpServer> { | 209 | protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect<BuiltMcpServer> { |
| 210 | const elicitationMode = sessionMeta.elicitationMode ?? "model"; | 210 | const elicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 211 | return buildMcpServerV2({ | 211 | return buildMcpServer({ |
| 212 | engine: this.sessionEngine, | 212 | engine: this.sessionEngine, |
| 213 | appsEnabled: false, | 213 | appsEnabled: false, |
| 214 | restoredAppsEnabled: sessionMeta.appsEnabled, | 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 | it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => { | 260 | it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => { |
| 261 | const harness = makeClientHarness(); | 261 | const harness = makeClientHarness(); |
| 262 | await harness.client.connect(harness.transport); | 262 | await harness.client.connect(harness.transport); |
| @@ -392,7 +392,7 @@ describe("McpAgentSessionDOBase SDK v2 session serving", () => { · 129 unmodified lines | |||
| 392 | expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`); | 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 | const harness = makeClientHarness(); | 396 | const harness = makeClientHarness(); |
| 397 | await harness.client.connect(harness.transport); | 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 | const state = new MemoryDurableObjectState(); | 423 | const state = new MemoryDurableObjectState(); |
| 424 | await state.storage.put("session-meta", { | 424 | await state.storage.put("session-meta", { |
| 425 | organizationId: ORGANIZATION_ID, | 425 | organizationId: ORGANIZATION_ID, |
| @@ -22,8 +22,12 @@ import { · 21 unmodified lines | |||
| 22 | type ResumeResponse, | 22 | type ResumeResponse, |
| 23 | } from "@executor-js/execution"; | 23 | } from "@executor-js/execution"; |
| 24 | import { | 24 | import { |
| 25 | appsEnabledForClientCapabilities, | ||
| 26 | clientCapabilitiesFromRequestBody, | ||
| 25 | PAUSED_APPROVAL_TIMEOUT_MS, | 27 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 26 | formatMcpExecutionOutcome, | 28 | formatMcpExecutionOutcome, |
| 29 | mcpRequestStatePrincipal, | ||
| 30 | requestBodyFromRequest, | ||
| 27 | type PausedExecutionHooks, | 31 | type PausedExecutionHooks, |
| 28 | type ResumeFallbackOutcome, | 32 | type ResumeFallbackOutcome, |
| 29 | } from "@executor-js/host-mcp/tool-server"; | 33 | } from "@executor-js/host-mcp/tool-server"; |
| @@ -33,13 +37,6 @@ import { · 3 unmodified lines | |||
| 33 | mcpResourceKey, | 37 | mcpResourceKey, |
| 34 | type McpResource, | 38 | type McpResource, |
| 35 | } from "@executor-js/host-mcp"; | 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 | import { | 40 | import { |
| 44 | readArtifactsEnabled, | 41 | readArtifactsEnabled, |
| 45 | readElicitationMode, | 42 | readElicitationMode, |
| @@ -149,7 +146,7 @@ export interface SessionMeta { · 103 unmodified lines | |||
| 149 | * unknown, which behaves as disabled until the next `initialize`. | 146 | * unknown, which behaves as disabled until the next `initialize`. |
| 150 | */ | 147 | */ |
| 151 | readonly appsEnabled?: boolean; | 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 | readonly createdAtMs?: number; | 150 | readonly createdAtMs?: number; |
| 154 | } | 151 | } |
| 155 | 152 | ||
| @@ -160,14 +157,14 @@ export interface BuiltMcpServer { · 4 unmodified lines | |||
| 160 | readonly modernRuntime?: BuiltModernMcpRuntime; | 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 | export interface ModernMcpServerRequestOptions { | 161 | export interface ModernMcpServerRequestOptions { |
| 165 | readonly appsEnabled: boolean; | 162 | readonly appsEnabled: boolean; |
| 166 | readonly requestStateSigningKey: Uint8Array | string; | 163 | readonly requestStateSigningKey: Uint8Array | string; |
| 167 | readonly requestStatePrincipal: string; | 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 | export interface BuiltModernMcpRuntime { | 168 | export interface BuiltModernMcpRuntime { |
| 172 | readonly engine: ExecutionEngine<Cause.YieldableError>; | 169 | readonly engine: ExecutionEngine<Cause.YieldableError>; |
| 173 | readonly buildServer: ( | 170 | readonly buildServer: ( |
| @@ -186,8 +183,8 @@ type ModernRuntimeAccess = · 12 unmodified lines | |||
| 186 | 183 | ||
| 187 | class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} | 184 | class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} |
| 188 | 185 | ||
| 189 | const LEGACY_V2_SESSION_META_KEY = "executor:mcp:v2:session-meta"; | 186 | const LEGACY_AGENT_SESSION_META_KEY = "executor:mcp:v2:session-meta"; |
| 190 | const LEGACY_V2_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms"; | 187 | const LEGACY_AGENT_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms"; |
| 191 | const MODERN_SESSION_META_KEY = "session-meta"; | 188 | const MODERN_SESSION_META_KEY = "session-meta"; |
| 192 | const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms"; | 189 | const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms"; |
| 193 | const MODERN_SESSION_KEY = "modern-session"; | 190 | const MODERN_SESSION_KEY = "modern-session"; |
| @@ -313,7 +310,7 @@ export abstract class McpAgentSessionDOBase< · 119 unmodified lines | |||
| 313 | dbHandle: TDbHandle, | 310 | dbHandle: TDbHandle, |
| 314 | ): Effect.Effect<BuiltMcpServer>; | 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 | protected buildModernMcpRuntime( | 314 | protected buildModernMcpRuntime( |
| 318 | _sessionMeta: SessionMeta, | 315 | _sessionMeta: SessionMeta, |
| 319 | _dbHandle: TDbHandle, | 316 | _dbHandle: TDbHandle, |
| @@ -448,7 +445,7 @@ export abstract class McpAgentSessionDOBase< · 128 unmodified lines | |||
| 448 | return Effect.promise(async () => { | 445 | return Effect.promise(async () => { |
| 449 | if (this.sessionMeta) return this.sessionMeta; | 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 | if (legacy) { | 449 | if (legacy) { |
| 453 | this.runtimeKind = "legacy"; | 450 | this.runtimeKind = "legacy"; |
| 454 | this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource }; | 451 | this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource }; |
| @@ -475,14 +472,14 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines | |||
| 475 | private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> { | 472 | private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> { |
| 476 | this.sessionMeta = sessionMeta; | 473 | this.sessionMeta = sessionMeta; |
| 477 | const key = | 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 | await this.ctx.storage.put(key, sessionMeta); | 476 | await this.ctx.storage.put(key, sessionMeta); |
| 480 | } | 477 | } |
| 481 | 478 | ||
| 482 | /** | 479 | /** |
| 483 | * Persist the MCP-Apps support negotiated at `initialize`, so a later cold | 480 | * Persist the MCP-Apps support negotiated at `initialize`, so a later cold |
| 484 | * restore can rebuild the server with it. Subclasses hand this to | 481 | * restore can rebuild the server with it. Subclasses hand this to |
| 485 | * `createExecutorMcpServer` as `onAppsEnabledChange`. | 482 | * `buildMcpServer` as `onAppsEnabledChange`. |
| 486 | * | 483 | * |
| 487 | * A no-op before meta exists: `initialize` always follows `init`, so there is | 484 | * A no-op before meta exists: `initialize` always follows `init`, so there is |
| 488 | * nothing to merge into and nothing worth failing the session over. | 485 | * nothing to merge into and nothing worth failing the session over. |
| @@ -504,7 +501,7 @@ export abstract class McpAgentSessionDOBase< · 15 unmodified lines | |||
| 504 | private async markActivity(now = Date.now()): Promise<void> { | 501 | private async markActivity(now = Date.now()): Promise<void> { |
| 505 | this.lastActivityMs = now; | 502 | this.lastActivityMs = now; |
| 506 | const key = | 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 | await Promise.all([ | 505 | await Promise.all([ |
| 509 | this.ctx.storage.put(key, now), | 506 | this.ctx.storage.put(key, now), |
| 510 | this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()), | 507 | this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()), |
| @@ -514,7 +511,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines | |||
| 514 | private async loadLastActivity(): Promise<number> { | 511 | private async loadLastActivity(): Promise<number> { |
| 515 | if (this.lastActivityMs > 0) return this.lastActivityMs; | 512 | if (this.lastActivityMs > 0) return this.lastActivityMs; |
| 516 | const key = | 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 | const stored = await this.ctx.storage.get<number>(key); | 515 | const stored = await this.ctx.storage.get<number>(key); |
| 519 | this.lastActivityMs = stored ?? 0; | 516 | this.lastActivityMs = stored ?? 0; |
| 520 | return this.lastActivityMs; | 517 | return this.lastActivityMs; |
| @@ -546,7 +543,7 @@ export abstract class McpAgentSessionDOBase< · 25 unmodified lines | |||
| 546 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), | 543 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), |
| 547 | Effect.ignore( | 544 | Effect.ignore( |
| 548 | Effect.tryPromise(() => | 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 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), | 568 | Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), |
| 572 | Effect.ignore( | 569 | Effect.ignore( |
| 573 | Effect.tryPromise(() => | 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 | private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void { | 889 | private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void { |
| 893 | transport.sessionId = this.sessionId; | 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 | // The installed transport's only additional session-validation bit is the | 892 | // The installed transport's only additional session-validation bit is the |
| 896 | // runtime `_initialized` boolean. Restoring just those transport fields | 893 | // runtime `_initialized` boolean. Restoring just those transport fields |
| 897 | // intentionally leaves McpServer client capabilities absent, so the | 894 | // intentionally leaves McpServer client capabilities absent, so the |
| @@ -993,7 +990,7 @@ export abstract class McpAgentSessionDOBase< · 95 unmodified lines | |||
| 993 | transport: WebStandardStreamableHTTPServerTransport, | 990 | transport: WebStandardStreamableHTTPServerTransport, |
| 994 | requestId: RequestId, | 991 | requestId: RequestId, |
| 995 | ): string | null { | 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 | // ID. The installed transport stores the exact request-id → stream-id map | 994 | // ID. The installed transport stores the exact request-id → stream-id map |
| 998 | // used by replay. Reading it lets the legacy compatibility prime share the | 995 | // used by replay. Reading it lets the legacy compatibility prime share the |
| 999 | // same replay stream as the eventual result without changing SDK code. | 996 | // same replay stream as the eventual result without changing SDK code. |
| @@ -9,7 +9,7 @@ import type { · 8 unmodified lines | |||
| 9 | } from "@executor-js/execution"; | 9 | } from "@executor-js/execution"; |
| 10 | import { defaultMcpResource } from "@executor-js/host-mcp"; | 10 | import { defaultMcpResource } from "@executor-js/host-mcp"; |
| 11 | import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server"; | 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 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | 13 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; |
| 14 | 14 | ||
| 15 | import { | 15 | import { |
| @@ -234,7 +234,7 @@ const makeHarness = () => { · 218 unmodified lines | |||
| 234 | Effect.succeed({ | 234 | Effect.succeed({ |
| 235 | engine, | 235 | engine, |
| 236 | buildServer: (options: ModernMcpServerRequestOptions) => | 236 | buildServer: (options: ModernMcpServerRequestOptions) => |
| 237 | buildMcpServerV2({ | 237 | buildMcpServer({ |
| 238 | engine, | 238 | engine, |
| 239 | elicitationMode: { mode: "native" }, | 239 | elicitationMode: { mode: "native" }, |
| 240 | pausedExecutionHooks: session.modernPausedExecutionHooks, | 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 | * Writes are deliberately best-effort: storage limits or outages never escape | 52 | * Writes are deliberately best-effort: storage limits or outages never escape |
| 53 | * into the transport's send path. Every call still returns a monotonic event | 53 | * into the transport's send path. Every call still returns a monotonic event |
| @@ -8,7 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | type McpModernServerBuilder, | 8 | type McpModernServerBuilder, |
| 9 | type Principal, | 9 | type Principal, |
| 10 | } from "@executor-js/host-mcp"; | 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 | import type { McpSessionProps } from "./agent-session-durable-object"; | 13 | import type { McpSessionProps } from "./agent-session-durable-object"; |
| 14 | import { | 14 | import { |
| @@ -149,7 +149,7 @@ const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service · 134 unmodified lines | |||
| 149 | build: (_principal, options) => { | 149 | build: (_principal, options) => { |
| 150 | builds.count += 1; | 150 | builds.count += 1; |
| 151 | const { resource: _resource, ...requestOptions } = options; | 151 | const { resource: _resource, ...requestOptions } = options; |
| 152 | return buildMcpServerV2({ | 152 | return buildMcpServer({ |
| 153 | engine, | 153 | engine, |
| 154 | elicitationMode: { mode: "native" }, | 154 | elicitationMode: { mode: "native" }, |
| 155 | ...requestOptions, | 155 | ...requestOptions, |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | clientCapabilitiesFromRequestBody, | 18 | clientCapabilitiesFromRequestBody, |
| 19 | mcpRequestStatePrincipal, | 19 | mcpRequestStatePrincipal, |
| 20 | verifyNativeRequestState, | 20 | verifyNativeRequestState, |
| 21 | } from "@executor-js/host-mcp/tool-server-v2"; | 21 | } from "@executor-js/host-mcp/tool-server"; |
| 22 | 22 | ||
| 23 | import type { McpSessionProps } from "./agent-session-durable-object"; | 23 | import type { McpSessionProps } from "./agent-session-durable-object"; |
| 24 | import type { McpExecutionOwnerDirectory } from "./execution-owner-directory"; | 24 | import type { McpExecutionOwnerDirectory } from "./execution-owner-directory"; |
| @@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 3 unmodified lines | |||
| 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| 5 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; | 5 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; |
| 6 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | 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 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; | 8 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; |
| 9 | import type { ExecutionEngine } from "@executor-js/execution"; | 9 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 10 | 10 | ||
| @@ -40,8 +40,12 @@ describe("MCP-Apps shell resource", () => { · 29 unmodified lines | |||
| 40 | const mcpServer = await Effect.runPromise( | 40 | const mcpServer = await Effect.runPromise( |
| 41 | // Artifacts are opt-in per connection; the shell resource only exists on | 41 | // Artifacts are opt-in per connection; the shell resource only exists on |
| 42 | // a session that asked for them. | 42 | // a session that asked for them. |
| 43 | createExecutorMcpServer({ | 43 | buildMcpServer({ |
| 44 | engine: stubEngine, | 44 | engine: stubEngine, |
| 45 | appsEnabled: false, | ||
| 46 | requestStateSigningKey: new Uint8Array(32).fill(17), | ||
| 47 | requestStatePrincipal: "shell-resource-test-principal", | ||
| 48 | sessionful: true, | ||
| 45 | loadAppShellHtml: loadMcpAppsShellHtml, | 49 | loadAppShellHtml: loadMcpAppsShellHtml, |
| 46 | artifactsEnabled: true, | 50 | artifactsEnabled: true, |
| 47 | }), | 51 | }), |
| @@ -38,7 +38,7 @@ import { chromium, type Browser, type Frame, type Page } from "playwright-core"; · 37 unmodified lines | |||
| 38 | import { createServer as createViteServer } from "vite"; | 38 | import { createServer as createViteServer } from "vite"; |
| 39 | import type * as Cause from "effect/Cause"; | 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 | import { loadMcpAppsShellHtml } from "../shell-html"; | 43 | import { loadMcpAppsShellHtml } from "../shell-html"; |
| 44 | 44 | ||
| @@ -1241,8 +1241,12 @@ const startMcpHarnessForEngine = async <E extends Cause.YieldableError>( · 1196 unmodified lines | |||
| 1241 | engine: ExecutionEngine<E>, | 1241 | engine: ExecutionEngine<E>, |
| 1242 | ): Promise<McpHarness> => { | 1242 | ): Promise<McpHarness> => { |
| 1243 | const mcpServer = await Effect.runPromise( | 1243 | const mcpServer = await Effect.runPromise( |
| 1244 | createExecutorMcpServer({ | 1244 | buildMcpServer({ |
| 1245 | engine, | 1245 | engine, |
| 1246 | appsEnabled: false, | ||
| 1247 | requestStateSigningKey: new Uint8Array(32).fill(19), | ||
| 1248 | requestStatePrincipal: "mcp-app-browser-test-principal", | ||
| 1249 | sessionful: true, | ||
| 1246 | loadAppShellHtml: loadMcpAppsShellHtml, | 1250 | loadAppShellHtml: loadMcpAppsShellHtml, |
| 1247 | artifacts: makeInMemoryArtifacts(), | 1251 | artifacts: makeInMemoryArtifacts(), |
| 1248 | // Artifacts are opt-in per connection; this harness drives the artifact | 1252 | // Artifacts are opt-in per connection; this harness drives the artifact |
| @@ -12,10 +12,6 @@ · 11 unmodified lines | |||
| 12 | "types": "./src/tool-server.ts", | 12 | "types": "./src/tool-server.ts", |
| 13 | "default": "./src/tool-server.ts" | 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 | "./mcp-apps": { | 15 | "./mcp-apps": { |
| 20 | "types": "./src/mcp-apps.ts", | 16 | "types": "./src/mcp-apps.ts", |
| 21 | "default": "./src/mcp-apps.ts" | 17 | "default": "./src/mcp-apps.ts" |
| @@ -52,12 +48,9 @@ · 30 unmodified lines | |||
| 52 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" | 48 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" |
| 53 | }, | 49 | }, |
| 54 | "dependencies": { | 50 | "dependencies": { |
| 55 | "@cfworker/json-schema": "^4.1.1", | ||
| 56 | "@executor-js/execution": "workspace:*", | 51 | "@executor-js/execution": "workspace:*", |
| 57 | "@executor-js/sdk": "workspace:*", | 52 | "@executor-js/sdk": "workspace:*", |
| 58 | "@modelcontextprotocol/core": "2.0.0", | 53 | "@modelcontextprotocol/core": "2.0.0", |
| 59 | "@modelcontextprotocol/ext-apps": "^1.7.4", | ||
| 60 | "@modelcontextprotocol/sdk": "^1.29.0", | ||
| 61 | "@modelcontextprotocol/server": "2.0.0", | 54 | "@modelcontextprotocol/server": "2.0.0", |
| 62 | "effect": "catalog:", | 55 | "effect": "catalog:", |
| 63 | "zod": "4.3.6" | 56 | "zod": "4.3.6" |
| @@ -65,6 +58,7 @@ · 1 unmodified line | |||
| 65 | "devDependencies": { | 58 | "devDependencies": { |
| 66 | "@effect/vitest": "catalog:", | 59 | "@effect/vitest": "catalog:", |
| 67 | "@modelcontextprotocol/client": "2.0.0", | 60 | "@modelcontextprotocol/client": "2.0.0", |
| 61 | "@modelcontextprotocol/sdk": "^1.29.0", | ||
| 68 | "@types/node": "catalog:", | 62 | "@types/node": "catalog:", |
| 69 | "bun-types": "catalog:", | 63 | "bun-types": "catalog:", |
| 70 | "vitest": "catalog:" | 64 | "vitest": "catalog:" |
| @@ -1,9 +1,7 @@ | |||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | 1 | import { describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import { Data, Effect } from "effect"; | 2 | import { Data, Effect } from "effect"; |
| 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 3 | import { Client } from "@modelcontextprotocol/client"; |
| 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | 4 | import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; |
| 5 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | ||
| 6 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; | ||
| 7 | import type * as Cause from "effect/Cause"; | 5 | import type * as Cause from "effect/Cause"; |
| 8 | 6 | ||
| 9 | import { | 7 | import { |
| @@ -18,7 +16,8 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 8 unmodified lines | |||
| 18 | import type { BindableConnection } from "./artifact-bindings"; | 16 | import type { BindableConnection } from "./artifact-bindings"; |
| 19 | import { readArtifactsEnabled } from "./browser-approval"; | 17 | import { readArtifactsEnabled } from "./browser-approval"; |
| 20 | import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact"; | 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 | /** The caller's connection inventory, as `create-artifact` sees it when it | 22 | /** The caller's connection inventory, as `create-artifact` sees it when it |
| 24 | * binds an artifact's integration roles. */ | 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 | const withClient = async <E extends Cause.YieldableError>( | 140 | const withClient = async <E extends Cause.YieldableError>( |
| 134 | engine: ExecutionEngine<E>, | 141 | engine: ExecutionEngine<E>, |
| 135 | capabilities: ClientCapabilities, | 142 | capabilities: ClientCapabilities, |
| @@ -137,8 +144,9 @@ const withClient = async <E extends Cause.YieldableError>( · 1 unmodified line | |||
| 137 | config?: Partial<ExecutorMcpServerConfig<E>>, | 144 | config?: Partial<ExecutorMcpServerConfig<E>>, |
| 138 | ) => { | 145 | ) => { |
| 139 | const mcpServer = await Effect.runPromise( | 146 | const mcpServer = await Effect.runPromise( |
| 140 | createExecutorMcpServer({ | 147 | buildMcpServer({ |
| 141 | engine, | 148 | engine, |
| 149 | ...SESSION_SERVER_OPTIONS, | ||
| 142 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), | 150 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), |
| 143 | // Artifacts are on by default and nearly every test in this file | 151 | // Artifacts are on by default and nearly every test in this file |
| 144 | // exercises the artifact surface; spelled out here anyway so the cases | 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 | const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>"; | 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 | 175 | // What a client that renders MCP Apps advertises at `initialize`. |
| 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 | ||
| 171 | const APPS_CAPS = { | 176 | const APPS_CAPS = { |
| 172 | extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, | 177 | extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, |
| 173 | } as unknown as ClientCapabilities; | 178 | } satisfies ClientCapabilities; |
| 174 | 179 | ||
| 175 | const NO_APPS_CAPS: ClientCapabilities = {}; | 180 | const NO_APPS_CAPS: ClientCapabilities = {}; |
| 176 | 181 | ||
| @@ -244,8 +249,9 @@ describe("MCP host — artifact tool visibility", () => { · 67 unmodified lines | |||
| 244 | it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => { | 249 | it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => { |
| 245 | const store = makeArtifactStore(); | 250 | const store = makeArtifactStore(); |
| 246 | const mcpServer = await Effect.runPromise( | 251 | const mcpServer = await Effect.runPromise( |
| 247 | createExecutorMcpServer({ | 252 | buildMcpServer({ |
| 248 | engine: makeStubEngine({}), | 253 | engine: makeStubEngine({}), |
| 254 | ...SESSION_SERVER_OPTIONS, | ||
| 249 | artifacts: store.port, | 255 | artifacts: store.port, |
| 250 | artifactsEnabled: true, | 256 | artifactsEnabled: true, |
| 251 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), | 257 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), |
| @@ -307,8 +313,9 @@ describe("MCP host — artifact tool visibility", () => { · 55 unmodified lines | |||
| 307 | it("renders inline on a restore that replays initialize without the initialized notification", async () => { | 313 | it("renders inline on a restore that replays initialize without the initialized notification", async () => { |
| 308 | const store = makeArtifactStore(); | 314 | const store = makeArtifactStore(); |
| 309 | const mcpServer = await Effect.runPromise( | 315 | const mcpServer = await Effect.runPromise( |
| 310 | createExecutorMcpServer({ | 316 | buildMcpServer({ |
| 311 | engine: makeStubEngine({}), | 317 | engine: makeStubEngine({}), |
| 318 | ...SESSION_SERVER_OPTIONS, | ||
| 312 | artifacts: store.port, | 319 | artifacts: store.port, |
| 313 | artifactsEnabled: true, | 320 | artifactsEnabled: true, |
| 314 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), | 321 | loadAppShellHtml: () => Promise.resolve(SHELL_HTML), |
| @@ -460,8 +467,9 @@ describe("MCP host — artifact tool visibility", () => { · 145 unmodified lines | |||
| 460 | it("registers no ui tools at all when no shell loader is configured", async () => { | 467 | it("registers no ui tools at all when no shell loader is configured", async () => { |
| 461 | const store = makeArtifactStore(); | 468 | const store = makeArtifactStore(); |
| 462 | const mcpServer = await Effect.runPromise( | 469 | const mcpServer = await Effect.runPromise( |
| 463 | createExecutorMcpServer({ | 470 | buildMcpServer({ |
| 464 | engine: makeStubEngine({}), | 471 | engine: makeStubEngine({}), |
| 472 | ...SESSION_SERVER_OPTIONS, | ||
| 465 | artifacts: store.port, | 473 | artifacts: store.port, |
| 466 | // Opted in, so the only thing withholding the surface is the missing | 474 | // Opted in, so the only thing withholding the surface is the missing |
| 467 | // shell loader — otherwise this would pass on the connection default. | 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 | await client.callTool({ | 1312 | await client.callTool({ |
| 1305 | name: "create-artifact", | 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 | expect(usage).toEqual(["created", "updated"]); | 1316 | expect(usage).toEqual(["created", "updated"]); |
| 1309 | 1317 | ||
| @@ -31,7 +31,7 @@ import { · 30 unmodified lines | |||
| 31 | } from "./index"; | 31 | } from "./index"; |
| 32 | import type { ExecutionEngine } from "@executor-js/execution"; | 32 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 33 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; | 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 | const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; | 36 | const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; |
| 37 | 37 | ||
| @@ -60,7 +60,7 @@ const testEngine: ExecutionEngine = { · 22 unmodified lines | |||
| 60 | const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({ | 60 | const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({ |
| 61 | build: (_principal, options) => { | 61 | build: (_principal, options) => { |
| 62 | const { resource: _resource, ...requestOptions } = options; | 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 | build: (_principal, options) => { | 175 | build: (_principal, options) => { |
| 176 | const { resource: _resource, ...requestOptions } = options; | 176 | const { resource: _resource, ...requestOptions } = options; |
| 177 | return Ref.set(appsEnabled, options.appsEnabled).pipe( | 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 | clientCapabilitiesFromRequest, | 24 | clientCapabilitiesFromRequest, |
| 25 | mcpRequestStatePrincipal, | 25 | mcpRequestStatePrincipal, |
| 26 | requestBodyFromRequest, | 26 | requestBodyFromRequest, |
| 27 | } from "./tool-server-v2"; | 27 | } from "./tool-server"; |
| 28 | 28 | ||
| 29 | // --------------------------------------------------------------------------- | 29 | // --------------------------------------------------------------------------- |
| 30 | // Provider-neutral MCP serving envelope. | 30 | // Provider-neutral MCP serving envelope. |
| @@ -14,7 +14,7 @@ import { · 13 unmodified lines | |||
| 14 | } from "./in-memory-session-store"; | 14 | } from "./in-memory-session-store"; |
| 15 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; | 15 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; |
| 16 | import { defaultMcpResource, type Principal } from "./seams"; | 16 | import { defaultMcpResource, type Principal } from "./seams"; |
| 17 | import { buildMcpServerV2 } from "./tool-server-v2"; | 17 | import { buildMcpServer } from "./tool-server"; |
| 18 | 18 | ||
| 19 | const TEST_PRINCIPAL: Principal = { | 19 | const TEST_PRINCIPAL: Principal = { |
| 20 | accountId: "acct_test", | 20 | accountId: "acct_test", |
| @@ -71,7 +71,7 @@ const makeElicitingEngine = (): { · 50 unmodified lines | |||
| 71 | }; | 71 | }; |
| 72 | 72 | ||
| 73 | describe("in-memory MCP session store", () => { | 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 | let buildOptions: McpBuildServerOptions | undefined; | 75 | let buildOptions: McpBuildServerOptions | undefined; |
| 76 | const sessions = makeInMemoryMcpSessionStore((_principal, options) => { | 76 | const sessions = makeInMemoryMcpSessionStore((_principal, options) => { |
| 77 | buildOptions = options; | 77 | buildOptions = options; |
| @@ -112,10 +112,10 @@ describe("in-memory MCP session store", () => { · 34 unmodified lines | |||
| 112 | expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array); | 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 | const { engine, resumedWith } = makeElicitingEngine(); | 116 | const { engine, resumedWith } = makeElicitingEngine(); |
| 117 | const sessions = makeInMemoryMcpSessionStore((_principal, options) => | 117 | const sessions = makeInMemoryMcpSessionStore((_principal, options) => |
| 118 | buildMcpServerV2({ | 118 | buildMcpServer({ |
| 119 | engine, | 119 | engine, |
| 120 | ...options, | 120 | ...options, |
| 121 | loadAppShellHtml: async () => "<html></html>", | 121 | loadAppShellHtml: async () => "<html></html>", |
| @@ -28,7 +28,7 @@ import { · 27 unmodified lines | |||
| 28 | type Principal, | 28 | type Principal, |
| 29 | type McpResource, | 29 | type McpResource, |
| 30 | } from "./seams"; | 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 | // In-process McpSessionStore — the single-node serving store, shared by every | 34 | // In-process McpSessionStore — the single-node serving store, shared by every |
| @@ -77,9 +77,9 @@ export interface McpBuildServerOptions { · 42 unmodified lines | |||
| 77 | * with `?artifacts=false`; opted out, the built server registers none of | 77 | * with `?artifacts=false`; opted out, the built server registers none of |
| 78 | * the artifact tools, resource, or skills. */ | 78 | * the artifact tools, resource, or skills. */ |
| 79 | readonly artifactsEnabled?: boolean; | 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 | readonly appsEnabled: false; | 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 | readonly requestStateSigningKey: Uint8Array; | 83 | readonly requestStateSigningKey: Uint8Array; |
| 84 | /** Stable authenticated owner bound into continuation state. */ | 84 | /** Stable authenticated owner bound into continuation state. */ |
| 85 | readonly requestStatePrincipal: string; | 85 | readonly requestStatePrincipal: string; |
| @@ -280,7 +280,7 @@ export const makeInMemoryMcpSessionStore = ( · 194 unmodified lines | |||
| 280 | Effect.gen(function* () { | 280 | Effect.gen(function* () { |
| 281 | const transport = new WebStandardStreamableHTTPServerTransport({ | 281 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| 282 | sessionIdGenerator: () => crypto.randomUUID(), | 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 | // server→client elicitation request. Other modes preserve the | 284 | // server→client elicitation request. Other modes preserve the |
| 285 | // store's existing single-JSON response behavior. | 285 | // store's existing single-JSON response behavior. |
| 286 | enableJsonResponse: readElicitationMode(request) !== "native", | 286 | enableJsonResponse: readElicitationMode(request) !== "native", |
| @@ -7,7 +7,7 @@ · 6 unmodified lines | |||
| 7 | // | 7 | // |
| 8 | // The executor tool assemblies (execute/resume tools, elicitation and browser | 8 | // The executor tool assemblies (execute/resume tools, elicitation and browser |
| 9 | // approval bridges, Zod input schemas) are a different center of gravity. They | 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 | // surface stays small and dependency-light. | 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 | it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => { | 39 | it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => { |
| 40 | await withClient( | 40 | await withClient( |
| 41 | (server) => { | 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 | * This is a wire-compatible local copy of the helpers currently published by | 4 | * This is a wire-compatible local copy of the helpers currently published by |
| 5 | * `@modelcontextprotocol/ext-apps/server`. The upstream package still targets | 5 | * the upstream ext-apps server package. Remove this module once |
| 6 | * the v1 SDK; remove this module once | ||
| 7 | * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved. | 6 | * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved. |
| 8 | */ | 7 | */ |
| 9 | import type { | 8 | import type { |
| @@ -19,7 +19,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; · 18 unmodified lines | |||
| 19 | // 2. McpSessionStore — owns the serving session lifecycle: create + forward + | 19 | // 2. McpSessionStore — owns the serving session lifecycle: create + forward + |
| 20 | // ownership, end to end, via a single `dispatch`. The store builds/forwards | 20 | // ownership, end to end, via a single `dispatch`. The store builds/forwards |
| 21 | // the transport and returns the transport `Response`. | 21 | // the transport and returns the transport `Response`. |
| 22 | // 3. McpModernServerBuilder — builds one stateless SDK v2 server for each | 22 | // 3. McpModernServerBuilder — builds one stateless MCP server for each |
| 23 | // authenticated modern request. The envelope owns handler/bus lifetime. | 23 | // authenticated modern request. The envelope owns handler/bus lifetime. |
| 24 | // | 24 | // |
| 25 | // There is deliberately NO envelope-level engine seam. Both server builders | 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 | >()("@executor-js/host-mcp/McpSessionStore") {} | 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 | /** Request-scoped inputs the envelope adds to a host's modern server config. */ | 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 | * The envelope owns the cached `createMcpHandler` and its subscriptions bus; | 294 | * The envelope owns the cached `createMcpHandler` and its subscriptions bus; |
| 295 | * providers own execution-stack construction and tool configuration here. | 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 | it.effect( | 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 | Effect.gen(function* () { | 100 | Effect.gen(function* () { |
| 101 | const transport = new ModernStdioClientTransport(stdioServer); | 101 | const transport = new ModernStdioClientTransport(stdioServer); |
| @@ -218,7 +218,7 @@ export type McpConnectionsPort = { · 217 unmodified lines | |||
| 218 | readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>; | 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 | | (ExecutionEngineConfig<E> & SharedMcpServerConfig) | 222 | | (ExecutionEngineConfig<E> & SharedMcpServerConfig) |
| 223 | | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig) | 223 | | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig) |
| 224 | | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true }) | 224 | | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true }) |
| @@ -260,19 +260,19 @@ export type ResumeFallbackOutcome = · 35 unmodified lines | |||
| 260 | readonly status: "execution_not_found"; | 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 | export type McpRequestJoinKeys = { | 264 | export type McpRequestJoinKeys = { |
| 265 | readonly requestId: string | number; | 265 | readonly requestId: string | number; |
| 266 | readonly sessionId?: string | undefined; | 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 | export type McpInputRequiredResult = InputRequiredResult; | 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 | export type McpHandlerResult = McpToolResult | McpInputRequiredResult; | 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 | export type RegisteredMcpTool = { | 276 | export type RegisteredMcpTool = { |
| 277 | readonly enable: () => void; | 277 | readonly enable: () => void; |
| 278 | readonly disable: () => void; | 278 | readonly disable: () => void; |
| @@ -338,7 +338,6 @@ export type NativeExecutionServices< · 59 unmodified lines | |||
| 338 | */ | 338 | */ |
| 339 | export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = { | 339 | export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = { |
| 340 | readonly server: Server; | 340 | readonly server: Server; |
| 341 | readonly era: "v1" | "v2"; | ||
| 342 | readonly initialAppsEnabled: boolean; | 341 | readonly initialAppsEnabled: boolean; |
| 343 | readonly getClientCapabilities: () => unknown | null; | 342 | readonly getClientCapabilities: () => unknown | null; |
| 344 | readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean }; | 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 | /** Assemble the shared Executor tools through one SDK-specific adapter. */ | 1004 | /** Assemble the shared Executor tools through one SDK-specific adapter. */ |
| 1006 | export const createExecutorMcpServerAssembly = < | 1005 | export const buildExecutorMcpTools = < |
| 1007 | E extends Cause.YieldableError, | 1006 | E extends Cause.YieldableError, |
| 1008 | Server, | 1007 | Server, |
| 1009 | RequestContext extends McpRequestJoinKeys, | 1008 | RequestContext extends McpRequestJoinKeys, |
| 1010 | >( | 1009 | >( |
| 1011 | config: ExecutorMcpServerConfig<E>, | 1010 | config: ExecutorMcpToolConfig<E>, |
| 1012 | createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>, | 1011 | createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>, |
| 1013 | ): Effect.Effect<Server> => | 1012 | ): Effect.Effect<Server> => |
| 1014 | Effect.gen(function* () { | 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 | debugLog("tool.visibility", { | 2161 | debugLog("tool.visibility", { |
| 2174 | clientCapabilities: clientCapabilities ?? null, | 2162 | clientCapabilities: clientCapabilities ?? null, |
| 2175 | elicitationSupport: assembly.getElicitationSupport(), | 2163 | elicitationSupport: assembly.getElicitationSupport(), |
| @@ -16,14 +16,14 @@ import { Effect } from "effect"; · 15 unmodified lines | |||
| 16 | import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; | 16 | import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; |
| 17 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | 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 | import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps"; | 20 | import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps"; |
| 21 | 21 | ||
| 22 | const REQUEST_STATE_KEY = new Uint8Array(32).fill(7); | 22 | const REQUEST_STATE_KEY = new Uint8Array(32).fill(7); |
| 23 | const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo"); | 23 | const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo"); |
| 24 | const APP_URI = "ui://executor/shell.html"; | 24 | const APP_URI = "ui://executor/shell.html"; |
| 25 | 25 | ||
| 26 | type TestV2Config = { | 26 | type TestServerConfig = { |
| 27 | readonly engine: ExecutionEngine; | 27 | readonly engine: ExecutionEngine; |
| 28 | readonly appsEnabled: boolean; | 28 | readonly appsEnabled: boolean; |
| 29 | readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" }; | 29 | readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" }; |
| @@ -52,14 +52,14 @@ const makeStubEngine = ( · 22 unmodified lines | |||
| 52 | }); | 52 | }); |
| 53 | 53 | ||
| 54 | const withClient = async ( | 54 | const withClient = async ( |
| 55 | config: TestV2Config, | 55 | config: TestServerConfig, |
| 56 | run: (client: Client) => Promise<void>, | 56 | run: (client: Client) => Promise<void>, |
| 57 | options?: { readonly manualInputRequired?: boolean }, | 57 | options?: { readonly manualInputRequired?: boolean }, |
| 58 | ) => { | 58 | ) => { |
| 59 | const handler = createMcpHandler( | 59 | const handler = createMcpHandler( |
| 60 | () => | 60 | () => |
| 61 | Effect.runPromise( | 61 | Effect.runPromise( |
| 62 | buildMcpServerV2({ | 62 | buildMcpServer({ |
| 63 | ...config, | 63 | ...config, |
| 64 | requestStateSigningKey: REQUEST_STATE_KEY, | 64 | requestStateSigningKey: REQUEST_STATE_KEY, |
| 65 | requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test", | 65 | requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test", |
| @@ -74,7 +74,7 @@ const withClient = async ( · 8 unmodified lines | |||
| 74 | ), | 74 | ), |
| 75 | }); | 75 | }); |
| 76 | const client = new Client( | 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 | capabilities: { elicitation: { form: {} } }, | 79 | capabilities: { elicitation: { form: {} } }, |
| 80 | versionNegotiation: { mode: { pin: "2026-07-28" } }, | 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 | it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => { | 105 | it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => { |
| 106 | await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => { | 106 | await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => { |
| 107 | const names = (await client.listTools()).tools.map(({ name }) => name); | 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 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Data, Deferred, Effect } from "effect"; | 2 | import { Data, Deferred, Effect } from "effect"; |
| 3 | import type * as Tracer from "effect/Tracer"; | 3 | import type * as Tracer from "effect/Tracer"; |
| 4 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 4 | import { Client } from "@modelcontextprotocol/client"; |
| 5 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | 5 | import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; |
| 6 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | ||
| 7 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | ||
| 8 | import type * as Cause from "effect/Cause"; | 6 | import type * as Cause from "effect/Cause"; |
| 9 | 7 | ||
| 10 | import { | 8 | import { |
| @@ -18,7 +16,7 @@ import type { ToolFileValue } from "@executor-js/sdk"; · 7 unmodified lines | |||
| 18 | import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; | 16 | import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; |
| 19 | 17 | ||
| 20 | import { | 18 | import { |
| 21 | createExecutorMcpServer, | 19 | buildMcpServer, |
| 22 | formatMcpExecutionOutcome, | 20 | formatMcpExecutionOutcome, |
| 23 | type ExecutorMcpServerConfig, | 21 | type ExecutorMcpServerConfig, |
| 24 | } from "./tool-server"; | 22 | } from "./tool-server"; |
| @@ -45,18 +43,25 @@ const makeStubEngine = <E extends Cause.YieldableError = never>(overrides: { · 20 unmodified lines | |||
| 45 | resume?: ExecutionEngine<E>["resume"]; | 43 | resume?: ExecutionEngine<E>["resume"]; |
| 46 | isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"]; | 44 | isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"]; |
| 47 | description?: string; | 45 | description?: string; |
| 48 | }): ExecutionEngine<E> => ({ | 46 | }): ExecutionEngine<E> => { |
| 49 | execute: overrides.execute ?? (() => Effect.succeed({ result: "default" })), | 47 | const execute: ExecutionEngine<E>["execute"] = |
| 50 | executeWithPause: | 48 | overrides.execute ?? (() => Effect.succeed({ result: "default" })); |
| 51 | overrides.executeWithPause ?? | 49 | return { |
| 52 | (() => Effect.succeed({ status: "completed", result: { result: "default" } })), | 50 | execute, |
| 53 | resume: overrides.resume ?? (() => Effect.succeed(null)), | 51 | executeWithPause: |
| 54 | isExecutionSettled: overrides.isExecutionSettled, | 52 | overrides.executeWithPause ?? |
| 55 | getPausedExecution: () => Effect.succeed(null), | 53 | ((code) => |
| 56 | pausedExecutionCount: () => Effect.succeed(0), | 54 | execute(code, { |
| 57 | hasPausedExecutions: () => Effect.succeed(false), | 55 | onElicitation: () => Effect.die("Unexpected elicitation in completed execution test"), |
| 58 | getDescription: Effect.succeed(overrides.description ?? "test executor"), | 56 | }).pipe(Effect.map((result) => ({ status: "completed" as const, result })))), |
| 59 | }); | 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 | type TestServerConfig<E extends Cause.YieldableError> = Pick< | 66 | type TestServerConfig<E extends Cause.YieldableError> = Pick< |
| 62 | ExecutorMcpServerConfig<E>, | 67 | ExecutorMcpServerConfig<E>, |
| @@ -76,7 +81,14 @@ const withClient = async <E extends Cause.YieldableError>( · 13 unmodified lines | |||
| 76 | config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer }, | 81 | config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer }, |
| 77 | ) => { | 82 | ) => { |
| 78 | const { tracer, ...serverConfig } = config ?? {}; | 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 | const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create); | 92 | const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create); |
| 81 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | 93 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| 82 | const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities }); | 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 | const ELICITATION_CAPS: ClientCapabilities = { | 166 | const ELICITATION_CAPS: ClientCapabilities = { |
| 155 | elicitation: { form: {}, url: {} }, | 167 | elicitation: { form: {}, url: {} }, |
| 156 | }; | 168 | }; |
| 157 | const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } }; | ||
| 158 | const NO_CAPS: ClientCapabilities = {}; | 169 | const NO_CAPS: ClientCapabilities = {}; |
| 159 | 170 | ||
| 160 | /** Extract the first text content from a callTool result. */ | 171 | /** Extract the first text content from a callTool result. */ |
| @@ -192,33 +203,15 @@ const toolFile = (input: { · 31 unmodified lines | |||
| 192 | byteLength: input.byteLength, | 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 | // Explicit native elicitation mode | 207 | // Explicit native elicitation mode |
| 216 | // --------------------------------------------------------------------------- | 208 | // --------------------------------------------------------------------------- |
| 217 | 209 | ||
| 218 | describe("MCP host server — native elicitation mode", () => { | 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 | const engine = makeStubEngine({ | 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 | await withNativeClient(engine, ELICITATION_CAPS, async (client) => { | 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 | it("browser approval mode does not auto-switch to native elicitation", async () => { | 765 | it("browser approval mode does not auto-switch to native elicitation", async () => { |
| 819 | let approvalUrlCalled = false; | 766 | let approvalUrlCalled = false; |
| 820 | let executeCalled = false; | 767 | let executeCalled = false; |
| @@ -837,11 +784,6 @@ describe("MCP host server — native elicitation mode", () => { · 16 unmodified lines | |||
| 837 | engine, | 784 | engine, |
| 838 | ELICITATION_CAPS, | 785 | ELICITATION_CAPS, |
| 839 | async (client) => { | 786 | async (client) => { |
| 840 | client.setRequestHandler(ElicitRequestSchema, async () => ({ | ||
| 841 | action: "accept" as const, | ||
| 842 | content: {}, | ||
| 843 | })); | ||
| 844 | |||
| 845 | const { tools } = await client.listTools(); | 787 | const { tools } = await client.listTools(); |
| 846 | expect(tools.map((t) => t.name)).toContain("resume"); | 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 | it("engine error is surfaced as isError result", async () => { | 815 | it("engine error is surfaced as isError result", async () => { |
| 924 | const engine = makeStubEngine({ | 816 | const engine = makeStubEngine({ |
| 925 | execute: () => | 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 | // Client WITHOUT elicitation (pause/resume path) | 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 | // Resume content parsing edge cases | 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 | // skills tool | 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 | import * as Cause from "effect/Cause"; | 8 | import * as Cause from "effect/Cause"; |
| 3 | import { Validator } from "@cfworker/json-schema"; | ||
| 4 | import { | 9 | import { |
| 5 | getUiCapability, | 10 | acceptedContent, |
| 6 | registerAppResource, | 11 | CLIENT_CAPABILITIES_META_KEY, |
| 7 | registerAppTool, | 12 | createRequestStateCodec, |
| 8 | } from "@modelcontextprotocol/ext-apps/server"; | 13 | fromJsonSchema, |
| 9 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 14 | inputRequired, |
| 10 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | 15 | inputResponse, |
| 11 | import type { | 16 | McpServer, |
| 12 | jsonSchemaValidator, | 17 | type CallToolResult, |
| 13 | JsonSchemaType, | 18 | type InputRequiredResult, |
| 14 | JsonSchemaValidator, | 19 | type ServerContext, |
| 15 | } from "@modelcontextprotocol/sdk/validation/types.js"; | 20 | } from "@modelcontextprotocol/server"; |
| 16 | import * as z from "zod/v4"; | 21 | import * as z from "zod/v4"; |
| 17 | 22 | ||
| 18 | import type { ElicitationContext, ElicitationHandler, ElicitationRequest } from "@executor-js/sdk"; | 23 | import type { ElicitationRequest } from "@executor-js/sdk"; |
| 19 | import { ElicitationResponse } from "@executor-js/sdk"; | ||
| 20 | 24 | ||
| 21 | import { | 25 | import { |
| 22 | createExecutorMcpServerAssembly, | 26 | getUiCapability, |
| 23 | elicitationRequestTag, | 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 | type ExecutorMcpAssembly, | 37 | type ExecutorMcpAssembly, |
| 25 | type ExecutorMcpServerConfig, | 38 | type ExecutorMcpToolConfig, |
| 26 | type McpHandlerResult, | 39 | type McpHandlerResult, |
| 27 | type McpRequestJoinKeys, | 40 | type McpRequestJoinKeys, |
| 28 | type McpToolResult, | 41 | type McpToolResult, |
| 29 | type NativeExecutionServices, | 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 | export type { | 46 | export type { |
| 34 | BrowserApprovalStore, | 47 | BrowserApprovalStore, |
| 35 | ExecutorMcpServerConfig, | 48 | ExecutorMcpToolConfig, |
| 36 | McpArtifactsPort, | 49 | McpArtifactsPort, |
| 37 | McpConnectionsPort, | 50 | McpConnectionsPort, |
| 38 | McpToolResult, | 51 | McpToolResult, |
| 39 | PausedExecutionHooks, | 52 | PausedExecutionHooks, |
| 40 | ResumeFallbackOutcome, | 53 | ResumeFallbackOutcome, |
| 41 | ResumeUnavailableStatus, | 54 | ResumeUnavailableStatus, |
| 42 | } from "./tool-server-shared"; | 55 | } from "./tool-server-core"; |
| 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 }; | ||
| 74 | 56 | ||
| 75 | const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean => | 57 | const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; |
| 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 | ); | ||
| 81 | 58 | ||
| 82 | const elicitationRequestUrl = (request: ElicitationRequest): string | undefined => | 59 | const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); |
| 83 | Match.value(request).pipe( | 60 | /** Verified payload carried by a modern native-elicitation continuation. */ |
| 84 | Match.tag("UrlElicitation", (url): string | undefined => url.url), | 61 | export type NativeRequestState = typeof NativeRequestStateSchema.Type; |
| 85 | Match.tag("FormElicitation", (): string | undefined => undefined), | 62 | const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); |
| 86 | Match.exhaustive, | ||
| 87 | ); | ||
| 88 | 63 | ||
| 89 | const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams = | 64 | type McpServerRequestContext = McpRequestJoinKeys & { |
| 90 | Match.type<ElicitationRequest>().pipe( | 65 | readonly serverContext: ServerContext; |
| 91 | Match.tag("UrlElicitation", (url) => ({ | 66 | }; |
| 92 | mode: "url" as const, | 67 | |
| 93 | message: url.message, | 68 | /** Configuration required to build an Executor MCP server. */ |
| 94 | url: url.url, | 69 | export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> = |
| 95 | elicitationId: url.elicitationId, | 70 | ExecutorMcpToolConfig<E> & { |
| 96 | })), | 71 | /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */ |
| 97 | Match.tag("FormElicitation", (form) => ({ | 72 | readonly appsEnabled: boolean; |
| 98 | message: form.message, | 73 | /** |
| 99 | requestedSchema: | 74 | * Register a connection-lifetime server whose capability-dependent behavior |
| 100 | Object.keys(form.requestedSchema).length === 0 | 75 | * follows the live initialize-negotiated state. Omitted for stateless modern |
| 101 | ? { type: "object" as const, properties: {} } | 76 | * request factories, which keep using {@link appsEnabled} as fixed policy. |
| 102 | : form.requestedSchema, | 77 | */ |
| 103 | })), | 78 | readonly sessionful?: boolean; |
| 104 | Match.exhaustive, | 79 | /** HMAC key used to sign opaque native-elicitation continuation state. */ |
| 105 | ); | 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 } => { | 104 | const requestStateBinding = (method: string, principal: string): string => |
| 108 | const capabilities = server.server.getClientCapabilities(); | 105 | `${method}\u0000${principal}`; |
| 109 | if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false }; | 106 | |
| 110 | const elicitation = capabilities.elicitation as Record<string, unknown>; | 107 | /** Route-level failure verifying untrusted modern continuation state. */ |
| 111 | return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) }; | 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 = ( | 138 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 115 | error: unknown, | 139 | typeof value === "object" && value !== null && !Array.isArray(value); |
| 116 | ): { name?: string; message: string; stack?: string } => { | 140 | |
| 117 | // oxlint-disable-next-line executor/no-instanceof-error -- boundary: SDK Promise rejection supplies unknown JS errors for debug logging only | 141 | const appsClientCapabilitiesFromUnknown = ( |
| 118 | if (error instanceof Error) { | 142 | capabilities: unknown, |
| 119 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed native Error detail is confined to opt-in debug logging | 143 | ): McpAppsClientCapabilities | null => { |
| 120 | return { name: error.name, message: error.message, stack: error.stack }; | 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 | 154 | return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; |
| 123 | return { message: String(error) }; | ||
| 124 | }; | 155 | }; |
| 125 | 156 | ||
| 126 | const makeMcpElicitationHandler = | 157 | const elicitationSupportFromUnknown = ( |
| 127 | ( | 158 | capabilities: unknown, |
| 128 | server: McpServer, | 159 | ): { readonly form: boolean; readonly url: boolean } => { |
| 129 | relatedRequestId: string | number, | 160 | if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) { |
| 130 | debugLog: (event: string, data: Record<string, unknown>) => void, | 161 | return { form: false, url: false }; |
| 131 | ): ElicitationHandler => | 162 | } |
| 132 | (context: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => { | 163 | const elicitation = capabilities.elicitation; |
| 133 | const { url: supportsUrl } = getElicitationSupport(server); | 164 | const hasExplicitModes = "form" in elicitation || "url" in elicitation; |
| 134 | const params = Match.value(context.request).pipe( | 165 | return { |
| 135 | Match.tag( | 166 | form: hasExplicitModes ? Boolean(elicitation.form) : true, |
| 136 | "UrlElicitation", | 167 | url: Boolean(elicitation.url), |
| 137 | (request): ElicitInputParams => | 168 | }; |
| 138 | supportsUrl | 169 | }; |
| 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 | ); | ||
| 151 | 170 | ||
| 152 | return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => { | 171 | /** Parse the MCP Apps capability subset from an already-decoded modern body. */ |
| 153 | debugLog("elicitation.request", { | 172 | export const clientCapabilitiesFromRequestBody = ( |
| 154 | requestTag: elicitationRequestTag(context.request), | 173 | body: unknown, |
| 155 | supportsUrl, | 174 | ): McpAppsClientCapabilities | null => { |
| 156 | message: context.request.message, | 175 | if (!isRecord(body)) return null; |
| 157 | hasRequestedSchema: requestedSchemaIsNonEmpty(context.request), | 176 | const params = body.params; |
| 158 | url: elicitationRequestUrl(context.request), | 177 | if (!isRecord(params)) return null; |
| 159 | clientCapabilities: server.server.getClientCapabilities() ?? null, | 178 | const metadata = params._meta; |
| 160 | }); | 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( | 184 | /** Parse a cloned HTTP request body without consuming the request itself. */ |
| 163 | params as Parameters<typeof server.server.elicitInput>[0], | 185 | export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> => |
| 164 | { relatedRequestId }, | 186 | Effect.tryPromise({ |
| 165 | ); | 187 | try: () => request.clone().json(), |
| 166 | debugLog("elicitation.response", { | 188 | catch: () => null, |
| 167 | requestTag: elicitationRequestTag(context.request), | 189 | }).pipe( |
| 168 | action: response.action, | 190 | Effect.match({ |
| 169 | hasContent: | 191 | onFailure: () => null, |
| 170 | typeof response.content === "object" && | 192 | onSuccess: (body) => body, |
| 171 | response.content !== null && | 193 | }), |
| 172 | Object.keys(response.content).length > 0, | 194 | ); |
| 173 | }); | 195 | |
| 174 | return { | 196 | /** |
| 175 | action: response.action as typeof ElicitationResponse.Type.action, | 197 | * Parse the MCP Apps capability subset from a modern request's `_meta` |
| 176 | content: response.content, | 198 | * envelope without consuming the request body used by the SDK handler. |
| 177 | }; | 199 | */ |
| 178 | }).pipe( | 200 | export const clientCapabilitiesFromRequest = ( |
| 179 | Effect.tapDefect((defect) => | 201 | request: Request, |
| 180 | Effect.sync(() => { | 202 | ): Effect.Effect<McpAppsClientCapabilities | null> => |
| 181 | debugLog("elicitation.error", { | 203 | requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody)); |
| 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 | }; | ||
| 194 | 204 | ||
| 195 | const requestJoinKeys = (extra: { | 205 | const requestJoinKeys = (context: ServerContext): McpServerRequestContext => ({ |
| 196 | readonly requestId: string | number; | 206 | requestId: context.mcpReq.id, |
| 197 | readonly sessionId?: string; | 207 | ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), |
| 198 | }): McpRequestJoinKeys => ({ | 208 | serverContext: context, |
| 199 | requestId: extra.requestId, | ||
| 200 | ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }), | ||
| 201 | }); | 209 | }); |
| 202 | 210 | ||
| 203 | const v1ToolResult = (result: McpHandlerResult): McpToolResult => | 211 | const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => { |
| 204 | "resultType" in result | 212 | const ui = metadata.ui; |
| 205 | ? { | 213 | if (!isRecord(ui)) return undefined; |
| 206 | content: [{ type: "text", text: "Input-required results are unavailable on SDK v1." }], | 214 | const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined; |
| 207 | isError: true, | 215 | const visibility = Array.isArray(ui.visibility) |
| 208 | } | 216 | ? ui.visibility.filter( |
| 209 | : result; | 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 | config: ExecutorMcpServerConfig<E>, | 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 | const server = new McpServer( | 299 | const server = new McpServer( |
| 215 | { name: "executor", version: "1.0.0" }, | 300 | { name: "executor", version: "1.0.0" }, |
| 216 | { | 301 | { |
| 217 | capabilities: { resources: {}, tools: {} }, | 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 | name, | 308 | name, |
| 224 | toolConfig, | 309 | toolConfig, |
| 225 | callback, | 310 | callback, |
| @@ -228,69 +313,151 @@ const createV1Assembly = <E extends Cause.YieldableError>( · 2 unmodified lines | |||
| 228 | return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( | 313 | return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>( |
| 229 | name, | 314 | name, |
| 230 | { ...toolConfig, inputSchema }, | 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 | name, | 321 | name, |
| 237 | toolConfig, | 322 | toolConfig, |
| 238 | callback, | 323 | callback, |
| 239 | ) => { | 324 | ) => { |
| 240 | const inputSchema = z.object(toolConfig.inputSchema); | 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 | server, | 343 | server, |
| 243 | name, | 344 | name, |
| 244 | { ...toolConfig, inputSchema }, | 345 | { ...toolConfig, inputSchema, _meta: metadata }, |
| 245 | async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))), | 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 | return { | 370 | return { |
| 250 | server, | 371 | server, |
| 251 | era: "v1", | 372 | initialAppsEnabled, |
| 252 | initialAppsEnabled: config.restoredAppsEnabled ?? false, | 373 | getClientCapabilities: () => |
| 253 | getClientCapabilities: () => server.server.getClientCapabilities() ?? null, | 374 | sessionful ? (server.server.getClientCapabilities() ?? null) : null, |
| 254 | getElicitationSupport: () => getElicitationSupport(server), | 375 | getElicitationSupport: () => |
| 376 | sessionful | ||
| 377 | ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) | ||
| 378 | : { form: true, url: true }, | ||
| 255 | getUiCapability: () => | 379 | getUiCapability: () => |
| 256 | getUiCapability( | 380 | sessionful |
| 257 | server.server.getClientCapabilities() as | 381 | ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities())) |
| 258 | | (ClientCapabilities & { extensions?: Record<string, unknown> }) | 382 | : config.appsEnabled |
| 259 | | null, | 383 | ? { mimeTypes: [RESOURCE_MIME_TYPE] } |
| 260 | ), | 384 | : undefined, |
| 261 | onInitialized: (callback) => { | 385 | onInitialized: (callback) => { |
| 262 | server.server.oninitialized = callback; | 386 | if (sessionful) server.server.oninitialized = callback; |
| 263 | }, | 387 | }, |
| 264 | registerTool, | 388 | registerTool, |
| 265 | registerAppTool: registerApp, | 389 | registerAppTool: registerApp, |
| 266 | registerAppResource: (name, uri, resourceConfig, callback) => { | 390 | registerAppResource: (name, uri, resourceConfig, callback) => { |
| 391 | if (!sessionful && !config.appsEnabled) return; | ||
| 267 | registerAppResource(server, name, uri, resourceConfig, async () => { | 392 | registerAppResource(server, name, uri, resourceConfig, async () => { |
| 268 | const result = await callback(); | 393 | const result = await callback(); |
| 269 | return { contents: [...result.contents] }; | 394 | return { contents: [...result.contents] }; |
| 270 | }); | 395 | }); |
| 271 | }, | 396 | }, |
| 272 | executeNative: <NativeE extends Cause.YieldableError>( | 397 | executeNative: <NativeE extends Cause.YieldableError>( |
| 273 | services: NativeExecutionServices<NativeE, McpRequestJoinKeys>, | 398 | services: NativeExecutionServices<NativeE, McpServerRequestContext>, |
| 274 | ) => | 399 | ) => |
| 275 | services.engine | 400 | Effect.gen(function* () { |
| 276 | .execute(services.code, { | 401 | const decodedState = decodeNativeRequestState( |
| 277 | onElicitation: makeMcpElicitationHandler( | 402 | services.requestContext.serverContext.mcpReq.requestState<unknown>(), |
| 278 | server, | 403 | ); |
| 279 | services.requestContext.requestId, | 404 | |
| 280 | services.debugLog, | 405 | if (Option.isSome(decodedState)) { |
| 281 | ), | 406 | const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId); |
| 282 | }) | 407 | if (!paused) return missingNativeExecution(decodedState.value.executionId); |
| 283 | .pipe(Effect.map(services.complete)), | 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 | 457 | * Stateless hosts must reuse the signing key across every request that can |
| 291 | * construction and native elicitation are confined to this assembly. | 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 | config: ExecutorMcpServerConfig<E>, | 462 | config: ExecutorMcpServerConfig<E>, |
| 295 | ): Effect.Effect<McpServer> => | 463 | ): Effect.Effect<McpServer> => buildExecutorMcpTools(config, () => createMcpAssembly(config)); |
| 296 | createExecutorMcpServerAssembly(config, () => createV1Assembly(config)); | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect"; | 1 | import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect"; |
| 2 | import * as http from "node:http"; | 2 | import * as http from "node:http"; |
| 3 | // Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients. | ||
| 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 4 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 5 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; | 6 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; |