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
5Load 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
58 "typecheck": "tsgo --noEmit",58 "typecheck": "tsgo --noEmit",
59 "test": "vitest run",59 "test": "vitest run",
60 "test:watch": "vitest",60 "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"
62 },63 },
63 "dependencies": {64 "dependencies": {
64 "@cfworker/json-schema": "^4.1.1",65 "@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.
9import { writeFileSync } from "node:fs";
10import { resolve } from "node:path";
11
12import { CallToolResultSchema } from "@modelcontextprotocol/core";
13
14const schema = CallToolResultSchema.toJSONSchema();
15const target = resolve(import.meta.dir, "../src/sdk/call-tool-result-schema.gen.ts");
16
17writeFileSync(
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.
23export const callToolResultJsonSchema = ${JSON.stringify(schema, null, 2)} as const;
24`,
25);
26console.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.
5export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { CallToolResultSchema } from "@modelcontextprotocol/core";
3
4import { 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
12describe("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
18export type McpClientModule = typeof import("@modelcontextprotocol/client");
19export type McpClientValidatorsModule =
20 typeof import("@modelcontextprotocol/client/validators/cf-worker");
21
22export type McpClientSdk = {
23 readonly client: McpClientModule;
24 readonly validators: McpClientValidatorsModule;
25};
26
27let loaded: McpClientSdk | undefined;
28let loading: Promise<McpClientSdk> | undefined;
29
30export 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
39export const mcpClientSdkIfLoaded = (): McpClientSdk | undefined => loaded;
packages/plugins/mcp/src/sdk/connection.ts+29−25
@@ -1,21 +1,17 @@
1import {1import type { Client, FetchLike, OAuthClientProvider } from "@modelcontextprotocol/client";
2 Client,
3 SSEClientTransport,
4 StreamableHTTPClientTransport,
5 type FetchLike,
6 type OAuthClientProvider,
7} from "@modelcontextprotocol/client";
8import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
9import { Effect, Layer, Predicate, Stream } from "effect";2import { Effect, Layer, Predicate, Stream } from "effect";
10import { HttpClient, HttpClientRequest } from "effect/unstable/http";3import { HttpClient, HttpClientRequest } from "effect/unstable/http";
11 4
12// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module5// NOTE: nothing from `@modelcontextprotocol/client` is imported eagerly —
13// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and6// value access goes through `loadMcpClientSdk` (see client-module.ts for the
14// `cross-spawn` eagerly at evaluation time, which crashes workerd (including7// isolate-startup cost rationale). `StdioClientTransport` additionally stays
15// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set8// out of even the lazy barrel: the upstream `@modelcontextprotocol/client/stdio`
16// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;9// entry imports Node process/stream and `cross-spawn` at evaluation time,
17// prod bundles that DO use stdio load it via a dynamic import inside the10// which crashes workerd (including vitest-pool-workers) with SIGSEGV on
18// stdio branch of `createMcpConnector`.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`.
14import { loadMcpClientSdk, type McpClientSdk } from "./client-module";
19 15
20import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types";16import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types";
21import {17import {
@@ -203,12 +199,12 @@ const fetchFromHttpClientLayer = ( · 181 unmodified lines
203// MCP plugin runs inside a Cloudflare Worker (executor.sh). The199// MCP plugin runs inside a Cloudflare Worker (executor.sh). The
204// cfworker validator does not use code generation and works in every200// cfworker validator does not use code generation and works in every
205// runtime we ship to.201// runtime we ship to.
206const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>202const createClient = (sdk: McpClientSdk, versionNegotiation?: { readonly mode: "auto" }): Client =>
207 new Client(203 new sdk.client.Client(
208 { name: "executor-mcp", version: "0.1.0" },204 { name: "executor-mcp", version: "0.1.0" },
209 {205 {
210 capabilities: { elicitation: { form: {}, url: {} } },206 capabilities: { elicitation: { form: {}, url: {} } },
211 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),207 jsonSchemaValidator: new sdk.validators.CfWorkerJsonSchemaValidator(),
212 ...(versionNegotiation === undefined ? {} : { versionNegotiation }),208 ...(versionNegotiation === undefined ? {} : { versionNegotiation }),
213 },209 },
214 );210 );
@@ -249,12 +245,20 @@ const connectionFailure = ( · 34 unmodified lines
249 245
250const connectClient = (input: {246const connectClient = (input: {
251 transport: string;247 transport: string;
252 createTransport: () => Parameters<Client["connect"]>[0];248 createTransport: (sdk: McpClientSdk) => Parameters<Client["connect"]>[0];
253 versionNegotiation?: { readonly mode: "auto" };249 versionNegotiation?: { readonly mode: "auto" };
254}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>250}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
255 Effect.gen(function* () {251 Effect.gen(function* () {
256 const client = createClient(input.versionNegotiation);252 const sdk = yield* Effect.tryPromise({
257 const transportInstance = input.createTransport();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);
258 262
259 yield* Effect.tryPromise({263 yield* Effect.tryPromise({
260 // Interruption (an HTTP 499 cancelling a health check, the discovery264 // Interruption (an HTTP 499 cancelling a health check, the discovery
@@ -345,8 +349,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 84 unmodified lines
345 const connectStreamableHttp = connectClient({349 const connectStreamableHttp = connectClient({
346 transport: "streamable-http",350 transport: "streamable-http",
347 versionNegotiation: { mode: "auto" },351 versionNegotiation: { mode: "auto" },
348 createTransport: () =>352 createTransport: (sdk) =>
349 new StreamableHTTPClientTransport(endpoint, {353 new sdk.client.StreamableHTTPClientTransport(endpoint, {
350 requestInit,354 requestInit,
351 authProvider: input.authProvider,355 authProvider: input.authProvider,
352 fetch,356 fetch,
@@ -355,8 +359,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 2 unmodified lines
355 359
356 const connectSse = connectClient({360 const connectSse = connectClient({
357 transport: "sse",361 transport: "sse",
358 createTransport: () =>362 createTransport: (sdk) =>
359 new SSEClientTransport(endpoint, {363 new sdk.client.SSEClientTransport(endpoint, {
360 requestInit,364 requestInit,
361 authProvider: input.authProvider,365 authProvider: input.authProvider,
362 fetch,366 fetch,
packages/plugins/mcp/src/sdk/http-status.test.ts+8−1
@@ -1,6 +1,13 @@
1import { describe, expect, it } from "@effect/vitest";1import { beforeAll, describe, expect, it } from "@effect/vitest";
2import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";2import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
3 3
4import { 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.
9beforeAll(() => loadMcpClientSdk());
10
4// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors11// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
5import { insufficientScopeFromCause } from "./http-status";12import { insufficientScopeFromCause } from "./http-status";
6 13
packages/plugins/mcp/src/sdk/http-status.ts+9−4
@@ -10,7 +10,10 @@ · 9 unmodified lines
10import { Option, Schema } from "effect";10import { Option, Schema } from "effect";
11 11
12import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";12import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
13import { 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).
16import { mcpClientSdkIfLoaded } from "./client-module";
14 17
15const SsePostErrorCause = Schema.Struct({ message: Schema.String });18const SsePostErrorCause = Schema.Struct({ message: Schema.String });
16const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);19const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
@@ -28,8 +31,10 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 11 unmodified lines
28 });31 });
29 32
30const statusFromTypedTransportError = (cause: unknown): number | undefined => {33const statusFromTypedTransportError = (cause: unknown): number | undefined => {
31 if (SdkHttpError.isInstance(cause)) return cause.status;34 const sdk = mcpClientSdkIfLoaded();
32 if (SseError.isInstance(cause)) {35 if (sdk === undefined) return undefined;
36 if (sdk.client.SdkHttpError.isInstance(cause)) return cause.status;
37 if (sdk.client.SseError.isInstance(cause)) {
33 const code = cause.code;38 const code = cause.code;
34 return code !== undefined && code >= 100 && code <= 599 ? code : undefined;39 return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
35 }40 }
@@ -54,7 +59,7 @@ const SDK_STEP_UP_EXHAUSTED_RE = · 18 unmodified lines
54 /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;59 /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;
55 60
56export const insufficientScopeFromCause = (cause: unknown): boolean =>61export const insufficientScopeFromCause = (cause: unknown): boolean =>
57 InsufficientScopeError.isInstance(cause) ||62 (mcpClientSdkIfLoaded()?.client.InsufficientScopeError.isInstance(cause) ?? false) ||
58 Option.match(decodeSsePostErrorCause(cause), {63 Option.match(decodeSsePostErrorCause(cause), {
59 onNone: () => false,64 onNone: () => false,
60 onSome: ({ message }) =>65 onSome: ({ message }) =>
packages/plugins/mcp/src/sdk/invoke.test.ts+7−1
@@ -1,4 +1,4 @@
1import { describe, expect, it } from "@effect/vitest";1import { beforeAll, describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3import { HttpServerResponse } from "effect/unstable/http";3import { HttpServerResponse } from "effect/unstable/http";
4 4
@@ -11,7 +11,13 @@ import { · 6 unmodified lines
11import { ElicitationResponse } from "@executor-js/sdk";11import { ElicitationResponse } from "@executor-js/sdk";
12import { serveTestHttpApp } from "@executor-js/sdk/testing";12import { serveTestHttpApp } from "@executor-js/sdk/testing";
13 13
14import { loadMcpClientSdk } from "./client-module";
14import { createMcpConnector, type McpConnection, type McpConnector } from "./connection";15import { 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.
20beforeAll(() => loadMcpClientSdk());
15import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";21import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";
16import { invokeMcpTool } from "./invoke";22import { invokeMcpTool } from "./invoke";
17 23
packages/plugins/mcp/src/sdk/invoke.ts+24−9
@@ -16,7 +16,11 @@ · 15 unmodified lines
16 16
17import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";17import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
18 18
19import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";19import 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).
23import { mcpClientSdkIfLoaded } from "./client-module";
20 24
21import {25import {
22 ElicitationId,26 ElicitationId,
@@ -59,13 +63,25 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 36 unmodified lines
59 ).test(message);63 ).test(message);
60};64};
61 65
62const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>66const asProtocolError = (cause: unknown): ProtocolError | undefined => {
67 const sdk = mcpClientSdkIfLoaded();
68 if (sdk === undefined) return undefined;
63 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass69 // 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 &&70 return cause instanceof sdk.client.ProtocolError ? cause : undefined;
65 (cause.code === ProtocolErrorCode.InvalidParams ||71};
66 cause.code === ProtocolErrorCode.MethodNotFound) &&72
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 provides73const isUnknownToolCause = (cause: unknown, toolName: string): boolean => {
68 isUnknownToolMessage(cause.message, toolName);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};
69 85
70// ---------------------------------------------------------------------------86// ---------------------------------------------------------------------------
71// Elicitation bridge — decode incoming MCP ElicitRequest, route through87// Elicitation bridge — decode incoming MCP ElicitRequest, route through
@@ -198,8 +214,7 @@ const useConnection = ( · 126 unmodified lines
198 });214 });
199 }215 }
200 const status = httpStatusFromCause(cause);216 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 shapes217 const protocolFailure = asProtocolError(cause) !== undefined;
202 const protocolFailure = cause instanceof ProtocolError;
203 return new McpInvocationError({218 return new McpInvocationError({
204 toolName,219 toolName,
205 message: `MCP tool call failed for ${toolName}`,220 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
2import type { HttpClient } from "effect/unstable/http";2import type { HttpClient } from "effect/unstable/http";
3 3
4import type { OAuthClientProvider } from "@modelcontextprotocol/client";4import type { OAuthClientProvider } from "@modelcontextprotocol/client";
5import { CallToolResultSchema } from "@modelcontextprotocol/core";5
6import { callToolResultJsonSchema } from "./call-tool-result-schema.gen";
6 7
7import {8import {
8 authToolFailure,9 authToolFailure,
@@ -399,7 +400,11 @@ type JsonSchemaObject = Record<string, unknown> & { · 390 unmodified lines
399 readonly properties?: Record<string, unknown>;400 readonly properties?: Record<string, unknown>;
400};401};
401 402
402const 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.
407const McpCallToolResultJsonSchema: JsonSchemaObject = callToolResultJsonSchema;
403 408
404const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {409const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {
405 const defaultStructuredContentSchema =410 const defaultStructuredContentSchema =