Unified · Split
| @@ -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. | |
| @@ -58,7 +58,8 @@ · 57 unmodified lines | ||
| 58 | 58 | "typecheck": "tsgo --noEmit", |
| 59 | 59 | "test": "vitest run", |
| 60 | 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 | 64 | "dependencies": { |
| 64 | 65 | "@cfworker/json-schema": "^4.1.1", |
| @@ -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}`); | |
| @@ -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; | |
| @@ -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 | +}); | |
| @@ -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; | |
| @@ -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"; | |
| 9 | 2 | import { Effect, Layer, Predicate, Stream } from "effect"; |
| 10 | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 11 | 4 | |
| 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"; | |
| 19 | 15 | |
| 20 | 16 | import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types"; |
| 21 | 17 | import { |
| @@ -203,12 +199,12 @@ const fetchFromHttpClientLayer = ( · 181 unmodified lines | ||
| 203 | 199 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The |
| 204 | 200 | // cfworker validator does not use code generation and works in every |
| 205 | 201 | // 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( | |
| 208 | 204 | { name: "executor-mcp", version: "0.1.0" }, |
| 209 | 205 | { |
| 210 | 206 | capabilities: { elicitation: { form: {}, url: {} } }, |
| 211 | − jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), | |
| 207 | + jsonSchemaValidator: new sdk.validators.CfWorkerJsonSchemaValidator(), | |
| 212 | 208 | ...(versionNegotiation === undefined ? {} : { versionNegotiation }), |
| 213 | 209 | }, |
| 214 | 210 | ); |
| @@ -249,12 +245,20 @@ const connectionFailure = ( · 34 unmodified lines | ||
| 249 | 245 | |
| 250 | 246 | const connectClient = (input: { |
| 251 | 247 | transport: string; |
| 252 | − createTransport: () => Parameters<Client["connect"]>[0]; | |
| 248 | + createTransport: (sdk: McpClientSdk) => Parameters<Client["connect"]>[0]; | |
| 253 | 249 | versionNegotiation?: { readonly mode: "auto" }; |
| 254 | 250 | }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> => |
| 255 | 251 | 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); | |
| 258 | 262 | |
| 259 | 263 | yield* Effect.tryPromise({ |
| 260 | 264 | // Interruption (an HTTP 499 cancelling a health check, the discovery |
| @@ -345,8 +349,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 84 unmodified lines | ||
| 345 | 349 | const connectStreamableHttp = connectClient({ |
| 346 | 350 | transport: "streamable-http", |
| 347 | 351 | versionNegotiation: { mode: "auto" }, |
| 348 | − createTransport: () => | |
| 349 | − new StreamableHTTPClientTransport(endpoint, { | |
| 352 | + createTransport: (sdk) => | |
| 353 | + new sdk.client.StreamableHTTPClientTransport(endpoint, { | |
| 350 | 354 | requestInit, |
| 351 | 355 | authProvider: input.authProvider, |
| 352 | 356 | fetch, |
| @@ -355,8 +359,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 2 unmodified lines | ||
| 355 | 359 | |
| 356 | 360 | const connectSse = connectClient({ |
| 357 | 361 | transport: "sse", |
| 358 | − createTransport: () => | |
| 359 | − new SSEClientTransport(endpoint, { | |
| 362 | + createTransport: (sdk) => | |
| 363 | + new sdk.client.SSEClientTransport(endpoint, { | |
| 360 | 364 | requestInit, |
| 361 | 365 | authProvider: input.authProvider, |
| 362 | 366 | fetch, |
| @@ -1,6 +1,13 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { beforeAll, describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; |
| 3 | 3 | |
| 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 | + | |
| 4 | 11 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors |
| 5 | 12 | import { insufficientScopeFromCause } from "./http-status"; |
| 6 | 13 | |
| @@ -10,7 +10,10 @@ · 9 unmodified lines | ||
| 10 | 10 | import { Option, Schema } from "effect"; |
| 11 | 11 | |
| 12 | 12 | 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"; | |
| 14 | 17 | |
| 15 | 18 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); |
| 16 | 19 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); |
| @@ -28,8 +31,10 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 11 unmodified lines | ||
| 28 | 31 | }); |
| 29 | 32 | |
| 30 | 33 | 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)) { | |
| 33 | 38 | const code = cause.code; |
| 34 | 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 | 59 | /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/; |
| 55 | 60 | |
| 56 | 61 | export const insufficientScopeFromCause = (cause: unknown): boolean => |
| 57 | − InsufficientScopeError.isInstance(cause) || | |
| 62 | + (mcpClientSdkIfLoaded()?.client.InsufficientScopeError.isInstance(cause) ?? false) || | |
| 58 | 63 | Option.match(decodeSsePostErrorCause(cause), { |
| 59 | 64 | onNone: () => false, |
| 60 | 65 | onSome: ({ message }) => |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { beforeAll, describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import { Effect, Predicate } from "effect"; |
| 3 | 3 | import { HttpServerResponse } from "effect/unstable/http"; |
| 4 | 4 | |
| @@ -11,7 +11,13 @@ import { · 6 unmodified lines | ||
| 11 | 11 | import { ElicitationResponse } from "@executor-js/sdk"; |
| 12 | 12 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 13 | 13 | |
| 14 | +import { loadMcpClientSdk } from "./client-module"; | |
| 14 | 15 | 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()); | |
| 15 | 21 | import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors"; |
| 16 | 22 | import { invokeMcpTool } from "./invoke"; |
| 17 | 23 | |
| @@ -16,7 +16,11 @@ · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; |
| 18 | 18 | |
| 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"; | |
| 20 | 24 | |
| 21 | 25 | import { |
| 22 | 26 | ElicitationId, |
| @@ -59,13 +63,25 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 36 unmodified lines | ||
| 59 | 63 | ).test(message); |
| 60 | 64 | }; |
| 61 | 65 | |
| 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; | |
| 63 | 69 | // 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 | +}; | |
| 69 | 85 | |
| 70 | 86 | // --------------------------------------------------------------------------- |
| 71 | 87 | // Elicitation bridge — decode incoming MCP ElicitRequest, route through |
| @@ -198,8 +214,7 @@ const useConnection = ( · 126 unmodified lines | ||
| 198 | 214 | }); |
| 199 | 215 | } |
| 200 | 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 shapes | |
| 202 | − const protocolFailure = cause instanceof ProtocolError; | |
| 217 | + const protocolFailure = asProtocolError(cause) !== undefined; | |
| 203 | 218 | return new McpInvocationError({ |
| 204 | 219 | toolName, |
| 205 | 220 | message: `MCP tool call failed for ${toolName}`, |
| @@ -2,7 +2,8 @@ import { Effect, Layer, Option, Result, Schema } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import type { HttpClient } from "effect/unstable/http"; |
| 3 | 3 | |
| 4 | 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/client"; |
| 5 | −import { CallToolResultSchema } from "@modelcontextprotocol/core"; | |
| 5 | + | |
| 6 | +import { callToolResultJsonSchema } from "./call-tool-result-schema.gen"; | |
| 6 | 7 | |
| 7 | 8 | import { |
| 8 | 9 | authToolFailure, |
| @@ -399,7 +400,11 @@ type JsonSchemaObject = Record<string, unknown> & { · 390 unmodified lines | ||
| 399 | 400 | readonly properties?: Record<string, unknown>; |
| 400 | 401 | }; |
| 401 | 402 | |
| 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; | |
| 403 | 408 | |
| 404 | 409 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { |
| 405 | 410 | const defaultStructuredContentSchema = |