From e6eaeffa7cd4413bd22209b1c1592515dba9332c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:46:31 -0700 Subject: [PATCH] Load the MCP client SDK lazily on first outbound connection --- .changeset/lazy-mcp-client-module.md | 5 + packages/plugins/mcp/package.json | 3 +- .../scripts/gen-call-tool-result-schema.ts | 26 ++ .../src/sdk/call-tool-result-schema.gen.ts | 395 ++++++++++++++++++ .../src/sdk/call-tool-result-schema.test.ts | 16 + packages/plugins/mcp/src/sdk/client-module.ts | 39 ++ packages/plugins/mcp/src/sdk/connection.ts | 54 +-- .../plugins/mcp/src/sdk/http-status.test.ts | 9 +- packages/plugins/mcp/src/sdk/http-status.ts | 13 +- packages/plugins/mcp/src/sdk/invoke.test.ts | 8 +- packages/plugins/mcp/src/sdk/invoke.ts | 33 +- packages/plugins/mcp/src/sdk/plugin.ts | 9 +- 12 files changed, 567 insertions(+), 43 deletions(-) create mode 100644 .changeset/lazy-mcp-client-module.md create mode 100644 packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts create mode 100644 packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts create mode 100644 packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts create mode 100644 packages/plugins/mcp/src/sdk/client-module.ts diff --git a/.changeset/lazy-mcp-client-module.md b/.changeset/lazy-mcp-client-module.md new file mode 100644 index 0000000000..309303275c --- /dev/null +++ b/.changeset/lazy-mcp-client-module.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +Load the MCP client SDK lazily on first outbound connection instead of at module evaluation. Runtimes that bundle the plugin (notably Cloudflare Workers) no longer pay the client package's module-eval memory and CPU on startup or on code paths that never dial an MCP server. diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 79deee56fd..251a23b88e 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -58,7 +58,8 @@ "typecheck": "tsgo --noEmit", "test": "vitest run", "test:watch": "vitest", - "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json", + "gen:call-tool-result-schema": "bun scripts/gen-call-tool-result-schema.ts" }, "dependencies": { "@cfworker/json-schema": "^4.1.1", diff --git a/packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts b/packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts new file mode 100644 index 0000000000..1842522cd9 --- /dev/null +++ b/packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts @@ -0,0 +1,26 @@ +// Regenerates src/sdk/call-tool-result-schema.gen.ts from the installed +// @modelcontextprotocol/core. The schema is baked into a generated file so +// the runtime never imports the core package (importing it costs ~8.6MB of +// heap per Cloudflare isolate; see client-module.ts). Run after bumping the +// core dependency: +// bun scripts/gen-call-tool-result-schema.ts +// call-tool-result-schema.test.ts fails if the generated copy drifts from the +// installed package. +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { CallToolResultSchema } from "@modelcontextprotocol/core"; + +const schema = CallToolResultSchema.toJSONSchema(); +const target = resolve(import.meta.dir, "../src/sdk/call-tool-result-schema.gen.ts"); + +writeFileSync( + target, + `// Generated by scripts/gen-call-tool-result-schema.ts — do not edit. +// JSON Schema of @modelcontextprotocol/core's CallToolResultSchema, baked in +// so the runtime never pays the core package's module-eval cost (see +// client-module.ts). call-tool-result-schema.test.ts guards drift. +export const callToolResultJsonSchema = ${JSON.stringify(schema, null, 2)} as const; +`, +); +console.log(`wrote ${target}`); diff --git a/packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts b/packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts new file mode 100644 index 0000000000..bba9107074 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts @@ -0,0 +1,395 @@ +// Generated by scripts/gen-call-tool-result-schema.ts — do not edit. +// JSON Schema of @modelcontextprotocol/core's CallToolResultSchema, baked in +// so the runtime never pays the core package's module-eval cost (see +// client-module.ts). call-tool-result-schema.test.ts guards drift. +export const callToolResultJsonSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + _meta: { + type: "object", + properties: { + "io.modelcontextprotocol/serverInfo": { + type: "object", + properties: { + name: { + type: "string", + }, + title: { + type: "string", + }, + icons: { + type: "array", + items: { + type: "object", + properties: { + src: { + type: "string", + }, + mimeType: { + type: "string", + }, + sizes: { + type: "array", + items: { + type: "string", + }, + }, + theme: { + type: "string", + enum: ["light", "dark"], + }, + }, + required: ["src"], + additionalProperties: false, + }, + }, + version: { + type: "string", + }, + websiteUrl: { + type: "string", + }, + description: { + type: "string", + }, + }, + required: ["name", "version"], + additionalProperties: false, + }, + }, + additionalProperties: {}, + }, + content: { + default: [], + type: "array", + items: { + anyOf: [ + { + type: "object", + properties: { + type: { + type: "string", + const: "text", + }, + text: { + type: "string", + }, + annotations: { + type: "object", + properties: { + audience: { + type: "array", + items: { + type: "string", + enum: ["user", "assistant"], + }, + }, + priority: { + type: "number", + minimum: 0, + maximum: 1, + }, + lastModified: { + type: "string", + format: "date-time", + pattern: + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + }, + }, + additionalProperties: false, + }, + _meta: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: {}, + }, + }, + required: ["type", "text"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { + type: "string", + const: "image", + }, + data: { + type: "string", + }, + mimeType: { + type: "string", + }, + annotations: { + type: "object", + properties: { + audience: { + type: "array", + items: { + type: "string", + enum: ["user", "assistant"], + }, + }, + priority: { + type: "number", + minimum: 0, + maximum: 1, + }, + lastModified: { + type: "string", + format: "date-time", + pattern: + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + }, + }, + additionalProperties: false, + }, + _meta: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: {}, + }, + }, + required: ["type", "data", "mimeType"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { + type: "string", + const: "audio", + }, + data: { + type: "string", + }, + mimeType: { + type: "string", + }, + annotations: { + type: "object", + properties: { + audience: { + type: "array", + items: { + type: "string", + enum: ["user", "assistant"], + }, + }, + priority: { + type: "number", + minimum: 0, + maximum: 1, + }, + lastModified: { + type: "string", + format: "date-time", + pattern: + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + }, + }, + additionalProperties: false, + }, + _meta: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: {}, + }, + }, + required: ["type", "data", "mimeType"], + additionalProperties: false, + }, + { + type: "object", + properties: { + name: { + type: "string", + }, + title: { + type: "string", + }, + icons: { + type: "array", + items: { + type: "object", + properties: { + src: { + type: "string", + }, + mimeType: { + type: "string", + }, + sizes: { + type: "array", + items: { + type: "string", + }, + }, + theme: { + type: "string", + enum: ["light", "dark"], + }, + }, + required: ["src"], + additionalProperties: false, + }, + }, + uri: { + type: "string", + }, + description: { + type: "string", + }, + mimeType: { + type: "string", + }, + size: { + type: "number", + }, + annotations: { + type: "object", + properties: { + audience: { + type: "array", + items: { + type: "string", + enum: ["user", "assistant"], + }, + }, + priority: { + type: "number", + minimum: 0, + maximum: 1, + }, + lastModified: { + type: "string", + format: "date-time", + pattern: + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + }, + }, + additionalProperties: false, + }, + _meta: { + type: "object", + properties: {}, + additionalProperties: {}, + }, + type: { + type: "string", + const: "resource_link", + }, + }, + required: ["name", "uri", "type"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { + type: "string", + const: "resource", + }, + resource: { + anyOf: [ + { + type: "object", + properties: { + uri: { + type: "string", + }, + mimeType: { + type: "string", + }, + _meta: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: {}, + }, + text: { + type: "string", + }, + }, + required: ["uri", "text"], + additionalProperties: false, + }, + { + type: "object", + properties: { + uri: { + type: "string", + }, + mimeType: { + type: "string", + }, + _meta: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: {}, + }, + blob: { + type: "string", + }, + }, + required: ["uri", "blob"], + additionalProperties: false, + }, + ], + }, + annotations: { + type: "object", + properties: { + audience: { + type: "array", + items: { + type: "string", + enum: ["user", "assistant"], + }, + }, + priority: { + type: "number", + minimum: 0, + maximum: 1, + }, + lastModified: { + type: "string", + format: "date-time", + pattern: + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + }, + }, + additionalProperties: false, + }, + _meta: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: {}, + }, + }, + required: ["type", "resource"], + additionalProperties: false, + }, + ], + }, + }, + structuredContent: {}, + isError: { + type: "boolean", + }, + }, + required: ["content"], + additionalProperties: {}, +} as const; diff --git a/packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts b/packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts new file mode 100644 index 0000000000..5016860939 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; + +import { callToolResultJsonSchema } from "./call-tool-result-schema.gen"; + +// The generated copy exists so the RUNTIME never imports +// @modelcontextprotocol/core (module eval costs ~8.6MB of heap per Cloudflare +// isolate; see client-module.ts). Tests may import it freely — this one pins +// the baked schema to the installed package so a core version bump that +// changes CallToolResultSchema fails here until the file is regenerated: +// bun scripts/gen-call-tool-result-schema.ts +describe("call-tool-result-schema.gen", () => { + it("matches the installed @modelcontextprotocol/core schema", () => { + expect(callToolResultJsonSchema).toStrictEqual(CallToolResultSchema.toJSONSchema()); + }); +}); diff --git a/packages/plugins/mcp/src/sdk/client-module.ts b/packages/plugins/mcp/src/sdk/client-module.ts new file mode 100644 index 0000000000..84db301164 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/client-module.ts @@ -0,0 +1,39 @@ +// --------------------------------------------------------------------------- +// Lazy loader for `@modelcontextprotocol/client`. Evaluating the client +// barrel costs ~11MB of heap and tens of ms of module eval (transports, jose, +// eventsource, its bundled protocol schemas) — per ISOLATE, at startup, when +// imported statically into a Cloudflare Worker bundle. Only code paths that +// actually dial an outbound MCP server need any of it, so the module graph is +// loaded on first connect and memoized; isolates that never touch outbound +// MCP never pay (2026-08-25 latency incident: per-isolate baseline memory is +// what collapses isolate reuse). +// +// `mcpClientSdkIfLoaded` exists for synchronous error classification +// (`SdkHttpError.isInstance` etc. in http-status.ts / invoke.ts): every error +// of those classes is CONSTRUCTED by this module, so if it was never loaded +// the cause under inspection cannot be one of them and `undefined` is a +// correct, not a lossy, answer. +// --------------------------------------------------------------------------- + +export type McpClientModule = typeof import("@modelcontextprotocol/client"); +export type McpClientValidatorsModule = + typeof import("@modelcontextprotocol/client/validators/cf-worker"); + +export type McpClientSdk = { + readonly client: McpClientModule; + readonly validators: McpClientValidatorsModule; +}; + +let loaded: McpClientSdk | undefined; +let loading: Promise | undefined; + +export const loadMcpClientSdk = (): Promise => + (loading ??= Promise.all([ + import("@modelcontextprotocol/client"), + import("@modelcontextprotocol/client/validators/cf-worker"), + ]).then(([client, validators]) => { + loaded = { client, validators }; + return loaded; + })); + +export const mcpClientSdkIfLoaded = (): McpClientSdk | undefined => loaded; diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 3035716858..af8c7fc067 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -1,21 +1,17 @@ -import { - Client, - SSEClientTransport, - StreamableHTTPClientTransport, - type FetchLike, - type OAuthClientProvider, -} from "@modelcontextprotocol/client"; -import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; +import type { Client, FetchLike, OAuthClientProvider } from "@modelcontextprotocol/client"; import { Effect, Layer, Predicate, Stream } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module -// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and -// `cross-spawn` eagerly at evaluation time, which crashes workerd (including -// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set -// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below; -// prod bundles that DO use stdio load it via a dynamic import inside the -// stdio branch of `createMcpConnector`. +// NOTE: nothing from `@modelcontextprotocol/client` is imported eagerly — +// value access goes through `loadMcpClientSdk` (see client-module.ts for the +// isolate-startup cost rationale). `StdioClientTransport` additionally stays +// out of even the lazy barrel: the upstream `@modelcontextprotocol/client/stdio` +// entry imports Node process/stream and `cross-spawn` at evaluation time, +// which crashes workerd (including vitest-pool-workers) with SIGSEGV on +// module instantiation. Cloud callers set `dangerouslyAllowStdioMCP: false` +// and never reach the stdio branch below; prod bundles that DO use stdio load +// it via the dynamic import inside the stdio branch of `createMcpConnector`. +import { loadMcpClientSdk, type McpClientSdk } from "./client-module"; import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types"; import { @@ -203,12 +199,12 @@ const fetchFromHttpClientLayer = ( // MCP plugin runs inside a Cloudflare Worker (executor.sh). The // cfworker validator does not use code generation and works in every // runtime we ship to. -const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client => - new Client( +const createClient = (sdk: McpClientSdk, versionNegotiation?: { readonly mode: "auto" }): Client => + new sdk.client.Client( { name: "executor-mcp", version: "0.1.0" }, { capabilities: { elicitation: { form: {}, url: {} } }, - jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), + jsonSchemaValidator: new sdk.validators.CfWorkerJsonSchemaValidator(), ...(versionNegotiation === undefined ? {} : { versionNegotiation }), }, ); @@ -249,12 +245,20 @@ const connectionFailure = ( const connectClient = (input: { transport: string; - createTransport: () => Parameters[0]; + createTransport: (sdk: McpClientSdk) => Parameters[0]; versionNegotiation?: { readonly mode: "auto" }; }): Effect.Effect => Effect.gen(function* () { - const client = createClient(input.versionNegotiation); - const transportInstance = input.createTransport(); + const sdk = yield* Effect.tryPromise({ + try: () => loadMcpClientSdk(), + catch: () => + new McpConnectionError({ + transport: input.transport, + message: "Failed to load MCP client module", + }), + }); + const client = createClient(sdk, input.versionNegotiation); + const transportInstance = input.createTransport(sdk); yield* Effect.tryPromise({ // Interruption (an HTTP 499 cancelling a health check, the discovery @@ -345,8 +349,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const connectStreamableHttp = connectClient({ transport: "streamable-http", versionNegotiation: { mode: "auto" }, - createTransport: () => - new StreamableHTTPClientTransport(endpoint, { + createTransport: (sdk) => + new sdk.client.StreamableHTTPClientTransport(endpoint, { requestInit, authProvider: input.authProvider, fetch, @@ -355,8 +359,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const connectSse = connectClient({ transport: "sse", - createTransport: () => - new SSEClientTransport(endpoint, { + createTransport: (sdk) => + new sdk.client.SSEClientTransport(endpoint, { requestInit, authProvider: input.authProvider, fetch, diff --git a/packages/plugins/mcp/src/sdk/http-status.test.ts b/packages/plugins/mcp/src/sdk/http-status.test.ts index 0344a71986..9bc9c65dac 100644 --- a/packages/plugins/mcp/src/sdk/http-status.test.ts +++ b/packages/plugins/mcp/src/sdk/http-status.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it } from "@effect/vitest"; +import { beforeAll, describe, expect, it } from "@effect/vitest"; import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; +import { loadMcpClientSdk } from "./client-module"; + +// Classification consults the lazily-loaded client module (client-module.ts); +// in prod every SDK error is preceded by a connect, which loads it. Mirror +// that precondition here — these tests construct SDK errors directly. +beforeAll(() => loadMcpClientSdk()); + // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors import { insufficientScopeFromCause } from "./http-status"; diff --git a/packages/plugins/mcp/src/sdk/http-status.ts b/packages/plugins/mcp/src/sdk/http-status.ts index 541c631f34..97db2d930b 100644 --- a/packages/plugins/mcp/src/sdk/http-status.ts +++ b/packages/plugins/mcp/src/sdk/http-status.ts @@ -10,7 +10,10 @@ import { Option, Schema } from "effect"; import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; -import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client"; +// The SDK error classes are reached through the lazy loader: any error of +// those classes was constructed by the loaded client module, so "not loaded" +// soundly classifies the cause as not-an-SDK-error (see client-module.ts). +import { mcpClientSdkIfLoaded } from "./client-module"; const SsePostErrorCause = Schema.Struct({ message: Schema.String }); const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); @@ -28,8 +31,10 @@ const statusFromSsePostError = (cause: unknown): number | undefined => }); const statusFromTypedTransportError = (cause: unknown): number | undefined => { - if (SdkHttpError.isInstance(cause)) return cause.status; - if (SseError.isInstance(cause)) { + const sdk = mcpClientSdkIfLoaded(); + if (sdk === undefined) return undefined; + if (sdk.client.SdkHttpError.isInstance(cause)) return cause.status; + if (sdk.client.SseError.isInstance(cause)) { const code = cause.code; return code !== undefined && code >= 100 && code <= 599 ? code : undefined; } @@ -54,7 +59,7 @@ const SDK_STEP_UP_EXHAUSTED_RE = /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/; export const insufficientScopeFromCause = (cause: unknown): boolean => - InsufficientScopeError.isInstance(cause) || + (mcpClientSdkIfLoaded()?.client.InsufficientScopeError.isInstance(cause) ?? false) || Option.match(decodeSsePostErrorCause(cause), { onNone: () => false, onSome: ({ message }) => diff --git a/packages/plugins/mcp/src/sdk/invoke.test.ts b/packages/plugins/mcp/src/sdk/invoke.test.ts index ce133bfd5f..ee9100fb6b 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "@effect/vitest"; +import { beforeAll, describe, expect, it } from "@effect/vitest"; import { Effect, Predicate } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; @@ -11,7 +11,13 @@ import { import { ElicitationResponse } from "@executor-js/sdk"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; +import { loadMcpClientSdk } from "./client-module"; import { createMcpConnector, type McpConnection, type McpConnector } from "./connection"; + +// Classification consults the lazily-loaded client module (client-module.ts); +// in prod every SDK error is preceded by a connect, which loads it. Mirror +// that precondition here — these tests construct SDK errors directly. +beforeAll(() => loadMcpClientSdk()); import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors"; import { invokeMcpTool } from "./invoke"; diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 46a08bc663..ad2a12e830 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -16,7 +16,11 @@ import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; -import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; +import type { ProtocolError } from "@modelcontextprotocol/client"; + +// SDK error classes come through the lazy loader; by the time a tool call can +// fail, the connect path has always loaded the module (see client-module.ts). +import { mcpClientSdkIfLoaded } from "./client-module"; import { ElicitationId, @@ -59,13 +63,25 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean ).test(message); }; -const isUnknownToolCause = (cause: unknown, toolName: string): boolean => +const asProtocolError = (cause: unknown): ProtocolError | undefined => { + const sdk = mcpClientSdkIfLoaded(); + if (sdk === undefined) return undefined; // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass - cause instanceof ProtocolError && - (cause.code === ProtocolErrorCode.InvalidParams || - cause.code === ProtocolErrorCode.MethodNotFound) && - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides - isUnknownToolMessage(cause.message, toolName); + return cause instanceof sdk.client.ProtocolError ? cause : undefined; +}; + +const isUnknownToolCause = (cause: unknown, toolName: string): boolean => { + const sdk = mcpClientSdkIfLoaded(); + const protocolError = asProtocolError(cause); + return ( + sdk !== undefined && + protocolError !== undefined && + (protocolError.code === sdk.client.ProtocolErrorCode.InvalidParams || + protocolError.code === sdk.client.ProtocolErrorCode.MethodNotFound) && + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: the narrowing above reaches the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides + isUnknownToolMessage(protocolError.message, toolName) + ); +}; // --------------------------------------------------------------------------- // Elicitation bridge — decode incoming MCP ElicitRequest, route through @@ -198,8 +214,7 @@ const useConnection = ( }); } const status = httpStatusFromCause(cause); - // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes - const protocolFailure = cause instanceof ProtocolError; + const protocolFailure = asProtocolError(cause) !== undefined; return new McpInvocationError({ toolName, message: `MCP tool call failed for ${toolName}`, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 375e4e2493..5002994236 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -2,7 +2,8 @@ import { Effect, Layer, Option, Result, Schema } from "effect"; import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/client"; -import { CallToolResultSchema } from "@modelcontextprotocol/core"; + +import { callToolResultJsonSchema } from "./call-tool-result-schema.gen"; import { authToolFailure, @@ -399,7 +400,11 @@ type JsonSchemaObject = Record & { readonly properties?: Record; }; -const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema(); +// Baked at generation time rather than derived from @modelcontextprotocol/core +// at module scope — importing core costs ~8.6MB of heap per Cloudflare isolate +// (see client-module.ts), and this schema is the only thing the plugin needs +// from it outside a live connection. +const McpCallToolResultJsonSchema: JsonSchemaObject = callToolResultJsonSchema; const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { const defaultStructuredContentSchema =