History

Load the MCP client SDK lazily on first outbound connection (#1747)

Rhys Sullivan committed 2 months ago

Commit 91062c21 parent 05c3b2212 files · +567 −43Browse files

Unified · Split

12 files
  1. .changeset/lazy-mcp-client-module.md +5 −0
  2. packages/plugins/mcp/package.json +2 −1
  3. packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts +26 −0
  4. packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts +395 −0
  5. packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts +16 −0
  6. packages/plugins/mcp/src/sdk/client-module.ts +39 −0
  7. packages/plugins/mcp/src/sdk/connection.ts +29 −25
  8. packages/plugins/mcp/src/sdk/http-status.test.ts +8 −1
  9. packages/plugins/mcp/src/sdk/http-status.ts +9 −4
  10. packages/plugins/mcp/src/sdk/invoke.test.ts +7 −1
  11. packages/plugins/mcp/src/sdk/invoke.ts +24 −9
  12. packages/plugins/mcp/src/sdk/plugin.ts +7 −2
.changeset/lazy-mcp-client-module.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/plugin-mcp": patch
3+---
4+
5+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.
packages/plugins/mcp/package.json+2−1
@@ -58,7 +58,8 @@ · 57 unmodified lines
5858 "typecheck": "tsgo --noEmit",
5959 "test": "vitest run",
6060 "test:watch": "vitest",
61− "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
61+ "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json",
62+ "gen:call-tool-result-schema": "bun scripts/gen-call-tool-result-schema.ts"
6263 },
6364 "dependencies": {
6465 "@cfworker/json-schema": "^4.1.1",
packages/plugins/mcp/scripts/gen-call-tool-result-schema.ts+26−0
@@ -0,0 +1,26 @@
1+// Regenerates src/sdk/call-tool-result-schema.gen.ts from the installed
2+// @modelcontextprotocol/core. The schema is baked into a generated file so
3+// the runtime never imports the core package (importing it costs ~8.6MB of
4+// heap per Cloudflare isolate; see client-module.ts). Run after bumping the
5+// core dependency:
6+// bun scripts/gen-call-tool-result-schema.ts
7+// call-tool-result-schema.test.ts fails if the generated copy drifts from the
8+// installed package.
9+import { writeFileSync } from "node:fs";
10+import { resolve } from "node:path";
11+
12+import { CallToolResultSchema } from "@modelcontextprotocol/core";
13+
14+const schema = CallToolResultSchema.toJSONSchema();
15+const target = resolve(import.meta.dir, "../src/sdk/call-tool-result-schema.gen.ts");
16+
17+writeFileSync(
18+ target,
19+ `// Generated by scripts/gen-call-tool-result-schema.ts — do not edit.
20+// JSON Schema of @modelcontextprotocol/core's CallToolResultSchema, baked in
21+// so the runtime never pays the core package's module-eval cost (see
22+// client-module.ts). call-tool-result-schema.test.ts guards drift.
23+export const callToolResultJsonSchema = ${JSON.stringify(schema, null, 2)} as const;
24+`,
25+);
26+console.log(`wrote ${target}`);
packages/plugins/mcp/src/sdk/call-tool-result-schema.gen.ts+395−0
@@ -0,0 +1,395 @@
1+// Generated by scripts/gen-call-tool-result-schema.ts — do not edit.
2+// JSON Schema of @modelcontextprotocol/core's CallToolResultSchema, baked in
3+// so the runtime never pays the core package's module-eval cost (see
4+// client-module.ts). call-tool-result-schema.test.ts guards drift.
5+export const callToolResultJsonSchema = {
6+ $schema: "https://json-schema.org/draft/2020-12/schema",
7+ type: "object",
8+ properties: {
9+ _meta: {
10+ type: "object",
11+ properties: {
12+ "io.modelcontextprotocol/serverInfo": {
13+ type: "object",
14+ properties: {
15+ name: {
16+ type: "string",
17+ },
18+ title: {
19+ type: "string",
20+ },
21+ icons: {
22+ type: "array",
23+ items: {
24+ type: "object",
25+ properties: {
26+ src: {
27+ type: "string",
28+ },
29+ mimeType: {
30+ type: "string",
31+ },
32+ sizes: {
33+ type: "array",
34+ items: {
35+ type: "string",
36+ },
37+ },
38+ theme: {
39+ type: "string",
40+ enum: ["light", "dark"],
41+ },
42+ },
43+ required: ["src"],
44+ additionalProperties: false,
45+ },
46+ },
47+ version: {
48+ type: "string",
49+ },
50+ websiteUrl: {
51+ type: "string",
52+ },
53+ description: {
54+ type: "string",
55+ },
56+ },
57+ required: ["name", "version"],
58+ additionalProperties: false,
59+ },
60+ },
61+ additionalProperties: {},
62+ },
63+ content: {
64+ default: [],
65+ type: "array",
66+ items: {
67+ anyOf: [
68+ {
69+ type: "object",
70+ properties: {
71+ type: {
72+ type: "string",
73+ const: "text",
74+ },
75+ text: {
76+ type: "string",
77+ },
78+ annotations: {
79+ type: "object",
80+ properties: {
81+ audience: {
82+ type: "array",
83+ items: {
84+ type: "string",
85+ enum: ["user", "assistant"],
86+ },
87+ },
88+ priority: {
89+ type: "number",
90+ minimum: 0,
91+ maximum: 1,
92+ },
93+ lastModified: {
94+ type: "string",
95+ format: "date-time",
96+ pattern:
97+ "^(?:(?:\\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)))$",
98+ },
99+ },
100+ additionalProperties: false,
101+ },
102+ _meta: {
103+ type: "object",
104+ propertyNames: {
105+ type: "string",
106+ },
107+ additionalProperties: {},
108+ },
109+ },
110+ required: ["type", "text"],
111+ additionalProperties: false,
112+ },
113+ {
114+ type: "object",
115+ properties: {
116+ type: {
117+ type: "string",
118+ const: "image",
119+ },
120+ data: {
121+ type: "string",
122+ },
123+ mimeType: {
124+ type: "string",
125+ },
126+ annotations: {
127+ type: "object",
128+ properties: {
129+ audience: {
130+ type: "array",
131+ items: {
132+ type: "string",
133+ enum: ["user", "assistant"],
134+ },
135+ },
136+ priority: {
137+ type: "number",
138+ minimum: 0,
139+ maximum: 1,
140+ },
141+ lastModified: {
142+ type: "string",
143+ format: "date-time",
144+ pattern:
145+ "^(?:(?:\\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)))$",
146+ },
147+ },
148+ additionalProperties: false,
149+ },
150+ _meta: {
151+ type: "object",
152+ propertyNames: {
153+ type: "string",
154+ },
155+ additionalProperties: {},
156+ },
157+ },
158+ required: ["type", "data", "mimeType"],
159+ additionalProperties: false,
160+ },
161+ {
162+ type: "object",
163+ properties: {
164+ type: {
165+ type: "string",
166+ const: "audio",
167+ },
168+ data: {
169+ type: "string",
170+ },
171+ mimeType: {
172+ type: "string",
173+ },
174+ annotations: {
175+ type: "object",
176+ properties: {
177+ audience: {
178+ type: "array",
179+ items: {
180+ type: "string",
181+ enum: ["user", "assistant"],
182+ },
183+ },
184+ priority: {
185+ type: "number",
186+ minimum: 0,
187+ maximum: 1,
188+ },
189+ lastModified: {
190+ type: "string",
191+ format: "date-time",
192+ pattern:
193+ "^(?:(?:\\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)))$",
194+ },
195+ },
196+ additionalProperties: false,
197+ },
198+ _meta: {
199+ type: "object",
200+ propertyNames: {
201+ type: "string",
202+ },
203+ additionalProperties: {},
204+ },
205+ },
206+ required: ["type", "data", "mimeType"],
207+ additionalProperties: false,
208+ },
209+ {
210+ type: "object",
211+ properties: {
212+ name: {
213+ type: "string",
214+ },
215+ title: {
216+ type: "string",
217+ },
218+ icons: {
219+ type: "array",
220+ items: {
221+ type: "object",
222+ properties: {
223+ src: {
224+ type: "string",
225+ },
226+ mimeType: {
227+ type: "string",
228+ },
229+ sizes: {
230+ type: "array",
231+ items: {
232+ type: "string",
233+ },
234+ },
235+ theme: {
236+ type: "string",
237+ enum: ["light", "dark"],
238+ },
239+ },
240+ required: ["src"],
241+ additionalProperties: false,
242+ },
243+ },
244+ uri: {
245+ type: "string",
246+ },
247+ description: {
248+ type: "string",
249+ },
250+ mimeType: {
251+ type: "string",
252+ },
253+ size: {
254+ type: "number",
255+ },
256+ annotations: {
257+ type: "object",
258+ properties: {
259+ audience: {
260+ type: "array",
261+ items: {
262+ type: "string",
263+ enum: ["user", "assistant"],
264+ },
265+ },
266+ priority: {
267+ type: "number",
268+ minimum: 0,
269+ maximum: 1,
270+ },
271+ lastModified: {
272+ type: "string",
273+ format: "date-time",
274+ pattern:
275+ "^(?:(?:\\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)))$",
276+ },
277+ },
278+ additionalProperties: false,
279+ },
280+ _meta: {
281+ type: "object",
282+ properties: {},
283+ additionalProperties: {},
284+ },
285+ type: {
286+ type: "string",
287+ const: "resource_link",
288+ },
289+ },
290+ required: ["name", "uri", "type"],
291+ additionalProperties: false,
292+ },
293+ {
294+ type: "object",
295+ properties: {
296+ type: {
297+ type: "string",
298+ const: "resource",
299+ },
300+ resource: {
301+ anyOf: [
302+ {
303+ type: "object",
304+ properties: {
305+ uri: {
306+ type: "string",
307+ },
308+ mimeType: {
309+ type: "string",
310+ },
311+ _meta: {
312+ type: "object",
313+ propertyNames: {
314+ type: "string",
315+ },
316+ additionalProperties: {},
317+ },
318+ text: {
319+ type: "string",
320+ },
321+ },
322+ required: ["uri", "text"],
323+ additionalProperties: false,
324+ },
325+ {
326+ type: "object",
327+ properties: {
328+ uri: {
329+ type: "string",
330+ },
331+ mimeType: {
332+ type: "string",
333+ },
334+ _meta: {
335+ type: "object",
336+ propertyNames: {
337+ type: "string",
338+ },
339+ additionalProperties: {},
340+ },
341+ blob: {
342+ type: "string",
343+ },
344+ },
345+ required: ["uri", "blob"],
346+ additionalProperties: false,
347+ },
348+ ],
349+ },
350+ annotations: {
351+ type: "object",
352+ properties: {
353+ audience: {
354+ type: "array",
355+ items: {
356+ type: "string",
357+ enum: ["user", "assistant"],
358+ },
359+ },
360+ priority: {
361+ type: "number",
362+ minimum: 0,
363+ maximum: 1,
364+ },
365+ lastModified: {
366+ type: "string",
367+ format: "date-time",
368+ pattern:
369+ "^(?:(?:\\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)))$",
370+ },
371+ },
372+ additionalProperties: false,
373+ },
374+ _meta: {
375+ type: "object",
376+ propertyNames: {
377+ type: "string",
378+ },
379+ additionalProperties: {},
380+ },
381+ },
382+ required: ["type", "resource"],
383+ additionalProperties: false,
384+ },
385+ ],
386+ },
387+ },
388+ structuredContent: {},
389+ isError: {
390+ type: "boolean",
391+ },
392+ },
393+ required: ["content"],
394+ additionalProperties: {},
395+} as const;
packages/plugins/mcp/src/sdk/call-tool-result-schema.test.ts+16−0
@@ -0,0 +1,16 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { CallToolResultSchema } from "@modelcontextprotocol/core";
3+
4+import { callToolResultJsonSchema } from "./call-tool-result-schema.gen";
5+
6+// The generated copy exists so the RUNTIME never imports
7+// @modelcontextprotocol/core (module eval costs ~8.6MB of heap per Cloudflare
8+// isolate; see client-module.ts). Tests may import it freely — this one pins
9+// the baked schema to the installed package so a core version bump that
10+// changes CallToolResultSchema fails here until the file is regenerated:
11+// bun scripts/gen-call-tool-result-schema.ts
12+describe("call-tool-result-schema.gen", () => {
13+ it("matches the installed @modelcontextprotocol/core schema", () => {
14+ expect(callToolResultJsonSchema).toStrictEqual(CallToolResultSchema.toJSONSchema());
15+ });
16+});
packages/plugins/mcp/src/sdk/client-module.ts+39−0
@@ -0,0 +1,39 @@
1+// ---------------------------------------------------------------------------
2+// Lazy loader for `@modelcontextprotocol/client`. Evaluating the client
3+// barrel costs ~11MB of heap and tens of ms of module eval (transports, jose,
4+// eventsource, its bundled protocol schemas) — per ISOLATE, at startup, when
5+// imported statically into a Cloudflare Worker bundle. Only code paths that
6+// actually dial an outbound MCP server need any of it, so the module graph is
7+// loaded on first connect and memoized; isolates that never touch outbound
8+// MCP never pay (2026-08-25 latency incident: per-isolate baseline memory is
9+// what collapses isolate reuse).
10+//
11+// `mcpClientSdkIfLoaded` exists for synchronous error classification
12+// (`SdkHttpError.isInstance` etc. in http-status.ts / invoke.ts): every error
13+// of those classes is CONSTRUCTED by this module, so if it was never loaded
14+// the cause under inspection cannot be one of them and `undefined` is a
15+// correct, not a lossy, answer.
16+// ---------------------------------------------------------------------------
17+
18+export type McpClientModule = typeof import("@modelcontextprotocol/client");
19+export type McpClientValidatorsModule =
20+ typeof import("@modelcontextprotocol/client/validators/cf-worker");
21+
22+export type McpClientSdk = {
23+ readonly client: McpClientModule;
24+ readonly validators: McpClientValidatorsModule;
25+};
26+
27+let loaded: McpClientSdk | undefined;
28+let loading: Promise<McpClientSdk> | undefined;
29+
30+export const loadMcpClientSdk = (): Promise<McpClientSdk> =>
31+ (loading ??= Promise.all([
32+ import("@modelcontextprotocol/client"),
33+ import("@modelcontextprotocol/client/validators/cf-worker"),
34+ ]).then(([client, validators]) => {
35+ loaded = { client, validators };
36+ return loaded;
37+ }));
38+
39+export const mcpClientSdkIfLoaded = (): McpClientSdk | undefined => loaded;
packages/plugins/mcp/src/sdk/connection.ts+29−25
@@ -1,21 +1,17 @@
1−import {
2− Client,
3− SSEClientTransport,
4− StreamableHTTPClientTransport,
5− type FetchLike,
6− type OAuthClientProvider,
7−} from "@modelcontextprotocol/client";
8−import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
1+import type { Client, FetchLike, OAuthClientProvider } from "@modelcontextprotocol/client";
92 import { Effect, Layer, Predicate, Stream } from "effect";
103 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
114
12−// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module
13−// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and
14−// `cross-spawn` eagerly at evaluation time, which crashes workerd (including
15−// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set
16−// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;
17−// prod bundles that DO use stdio load it via a dynamic import inside the
18−// stdio branch of `createMcpConnector`.
5+// NOTE: nothing from `@modelcontextprotocol/client` is imported eagerly —
6+// value access goes through `loadMcpClientSdk` (see client-module.ts for the
7+// isolate-startup cost rationale). `StdioClientTransport` additionally stays
8+// out of even the lazy barrel: the upstream `@modelcontextprotocol/client/stdio`
9+// entry imports Node process/stream and `cross-spawn` at evaluation time,
10+// which crashes workerd (including vitest-pool-workers) with SIGSEGV on
11+// module instantiation. Cloud callers set `dangerouslyAllowStdioMCP: false`
12+// and never reach the stdio branch below; prod bundles that DO use stdio load
13+// it via the dynamic import inside the stdio branch of `createMcpConnector`.
14+import { loadMcpClientSdk, type McpClientSdk } from "./client-module";
1915
2016 import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types";
2117 import {
@@ -203,12 +199,12 @@ const fetchFromHttpClientLayer = ( · 181 unmodified lines
203199 // MCP plugin runs inside a Cloudflare Worker (executor.sh). The
204200 // cfworker validator does not use code generation and works in every
205201 // runtime we ship to.
206−const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>
207− new Client(
202+const createClient = (sdk: McpClientSdk, versionNegotiation?: { readonly mode: "auto" }): Client =>
203+ new sdk.client.Client(
208204 { name: "executor-mcp", version: "0.1.0" },
209205 {
210206 capabilities: { elicitation: { form: {}, url: {} } },
211− jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
207+ jsonSchemaValidator: new sdk.validators.CfWorkerJsonSchemaValidator(),
212208 ...(versionNegotiation === undefined ? {} : { versionNegotiation }),
213209 },
214210 );
@@ -249,12 +245,20 @@ const connectionFailure = ( · 34 unmodified lines
249245
250246 const connectClient = (input: {
251247 transport: string;
252− createTransport: () => Parameters<Client["connect"]>[0];
248+ createTransport: (sdk: McpClientSdk) => Parameters<Client["connect"]>[0];
253249 versionNegotiation?: { readonly mode: "auto" };
254250 }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
255251 Effect.gen(function* () {
256− const client = createClient(input.versionNegotiation);
257− const transportInstance = input.createTransport();
252+ const sdk = yield* Effect.tryPromise({
253+ try: () => loadMcpClientSdk(),
254+ catch: () =>
255+ new McpConnectionError({
256+ transport: input.transport,
257+ message: "Failed to load MCP client module",
258+ }),
259+ });
260+ const client = createClient(sdk, input.versionNegotiation);
261+ const transportInstance = input.createTransport(sdk);
258262
259263 yield* Effect.tryPromise({
260264 // Interruption (an HTTP 499 cancelling a health check, the discovery
@@ -345,8 +349,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 84 unmodified lines
345349 const connectStreamableHttp = connectClient({
346350 transport: "streamable-http",
347351 versionNegotiation: { mode: "auto" },
348− createTransport: () =>
349− new StreamableHTTPClientTransport(endpoint, {
352+ createTransport: (sdk) =>
353+ new sdk.client.StreamableHTTPClientTransport(endpoint, {
350354 requestInit,
351355 authProvider: input.authProvider,
352356 fetch,
@@ -355,8 +359,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 2 unmodified lines
355359
356360 const connectSse = connectClient({
357361 transport: "sse",
358− createTransport: () =>
359− new SSEClientTransport(endpoint, {
362+ createTransport: (sdk) =>
363+ new sdk.client.SSEClientTransport(endpoint, {
360364 requestInit,
361365 authProvider: input.authProvider,
362366 fetch,
packages/plugins/mcp/src/sdk/http-status.test.ts+8−1
@@ -1,6 +1,13 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { beforeAll, describe, expect, it } from "@effect/vitest";
22 import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
33
4+import { loadMcpClientSdk } from "./client-module";
5+
6+// Classification consults the lazily-loaded client module (client-module.ts);
7+// in prod every SDK error is preceded by a connect, which loads it. Mirror
8+// that precondition here — these tests construct SDK errors directly.
9+beforeAll(() => loadMcpClientSdk());
10+
411 // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
512 import { insufficientScopeFromCause } from "./http-status";
613
packages/plugins/mcp/src/sdk/http-status.ts+9−4
@@ -10,7 +10,10 @@ · 9 unmodified lines
1010 import { Option, Schema } from "effect";
1111
1212 import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
13−import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client";
13+// The SDK error classes are reached through the lazy loader: any error of
14+// those classes was constructed by the loaded client module, so "not loaded"
15+// soundly classifies the cause as not-an-SDK-error (see client-module.ts).
16+import { mcpClientSdkIfLoaded } from "./client-module";
1417
1518 const SsePostErrorCause = Schema.Struct({ message: Schema.String });
1619 const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
@@ -28,8 +31,10 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 11 unmodified lines
2831 });
2932
3033 const statusFromTypedTransportError = (cause: unknown): number | undefined => {
31− if (SdkHttpError.isInstance(cause)) return cause.status;
32− if (SseError.isInstance(cause)) {
34+ const sdk = mcpClientSdkIfLoaded();
35+ if (sdk === undefined) return undefined;
36+ if (sdk.client.SdkHttpError.isInstance(cause)) return cause.status;
37+ if (sdk.client.SseError.isInstance(cause)) {
3338 const code = cause.code;
3439 return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
3540 }
@@ -54,7 +59,7 @@ const SDK_STEP_UP_EXHAUSTED_RE = · 18 unmodified lines
5459 /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;
5560
5661 export const insufficientScopeFromCause = (cause: unknown): boolean =>
57− InsufficientScopeError.isInstance(cause) ||
62+ (mcpClientSdkIfLoaded()?.client.InsufficientScopeError.isInstance(cause) ?? false) ||
5863 Option.match(decodeSsePostErrorCause(cause), {
5964 onNone: () => false,
6065 onSome: ({ message }) =>
packages/plugins/mcp/src/sdk/invoke.test.ts+7−1
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { beforeAll, describe, expect, it } from "@effect/vitest";
22 import { Effect, Predicate } from "effect";
33 import { HttpServerResponse } from "effect/unstable/http";
44
@@ -11,7 +11,13 @@ import { · 6 unmodified lines
1111 import { ElicitationResponse } from "@executor-js/sdk";
1212 import { serveTestHttpApp } from "@executor-js/sdk/testing";
1313
14+import { loadMcpClientSdk } from "./client-module";
1415 import { createMcpConnector, type McpConnection, type McpConnector } from "./connection";
16+
17+// Classification consults the lazily-loaded client module (client-module.ts);
18+// in prod every SDK error is preceded by a connect, which loads it. Mirror
19+// that precondition here — these tests construct SDK errors directly.
20+beforeAll(() => loadMcpClientSdk());
1521 import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";
1622 import { invokeMcpTool } from "./invoke";
1723
packages/plugins/mcp/src/sdk/invoke.ts+24−9
@@ -16,7 +16,11 @@ · 15 unmodified lines
1616
1717 import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
1818
19−import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
19+import type { ProtocolError } from "@modelcontextprotocol/client";
20+
21+// SDK error classes come through the lazy loader; by the time a tool call can
22+// fail, the connect path has always loaded the module (see client-module.ts).
23+import { mcpClientSdkIfLoaded } from "./client-module";
2024
2125 import {
2226 ElicitationId,
@@ -59,13 +63,25 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 36 unmodified lines
5963 ).test(message);
6064 };
6165
62−const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>
66+const asProtocolError = (cause: unknown): ProtocolError | undefined => {
67+ const sdk = mcpClientSdkIfLoaded();
68+ if (sdk === undefined) return undefined;
6369 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass
64− cause instanceof ProtocolError &&
65− (cause.code === ProtocolErrorCode.InvalidParams ||
66− cause.code === ProtocolErrorCode.MethodNotFound) &&
67− // 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
68− isUnknownToolMessage(cause.message, toolName);
70+ return cause instanceof sdk.client.ProtocolError ? cause : undefined;
71+};
72+
73+const isUnknownToolCause = (cause: unknown, toolName: string): boolean => {
74+ const sdk = mcpClientSdkIfLoaded();
75+ const protocolError = asProtocolError(cause);
76+ return (
77+ sdk !== undefined &&
78+ protocolError !== undefined &&
79+ (protocolError.code === sdk.client.ProtocolErrorCode.InvalidParams ||
80+ protocolError.code === sdk.client.ProtocolErrorCode.MethodNotFound) &&
81+ // 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
82+ isUnknownToolMessage(protocolError.message, toolName)
83+ );
84+};
6985
7086 // ---------------------------------------------------------------------------
7187 // Elicitation bridge — decode incoming MCP ElicitRequest, route through
@@ -198,8 +214,7 @@ const useConnection = ( · 126 unmodified lines
198214 });
199215 }
200216 const status = httpStatusFromCause(cause);
201− // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes
202− const protocolFailure = cause instanceof ProtocolError;
217+ const protocolFailure = asProtocolError(cause) !== undefined;
203218 return new McpInvocationError({
204219 toolName,
205220 message: `MCP tool call failed for ${toolName}`,
packages/plugins/mcp/src/sdk/plugin.ts+7−2
@@ -2,7 +2,8 @@ import { Effect, Layer, Option, Result, Schema } from "effect"; · 1 unmodified line
22 import type { HttpClient } from "effect/unstable/http";
33
44 import type { OAuthClientProvider } from "@modelcontextprotocol/client";
5−import { CallToolResultSchema } from "@modelcontextprotocol/core";
5+
6+import { callToolResultJsonSchema } from "./call-tool-result-schema.gen";
67
78 import {
89 authToolFailure,
@@ -399,7 +400,11 @@ type JsonSchemaObject = Record<string, unknown> & { · 390 unmodified lines
399400 readonly properties?: Record<string, unknown>;
400401 };
401402
402−const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema();
403+// Baked at generation time rather than derived from @modelcontextprotocol/core
404+// at module scope — importing core costs ~8.6MB of heap per Cloudflare isolate
405+// (see client-module.ts), and this schema is the only thing the plugin needs
406+// from it outside a live connection.
407+const McpCallToolResultJsonSchema: JsonSchemaObject = callToolResultJsonSchema;
403408
404409 const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {
405410 const defaultStructuredContentSchema =