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 | "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", |
| @@ -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 { | 1 | import type { Client, FetchLike, OAuthClientProvider } from "@modelcontextprotocol/client"; |
| 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"; | ||
| 9 | import { Effect, Layer, Predicate, Stream } from "effect"; | 2 | import { Effect, Layer, Predicate, Stream } from "effect"; |
| 10 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 11 | 4 | ||
| 12 | // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module | 5 | // NOTE: nothing from `@modelcontextprotocol/client` is imported eagerly — |
| 13 | // (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and | 6 | // value access goes through `loadMcpClientSdk` (see client-module.ts for the |
| 14 | // `cross-spawn` eagerly at evaluation time, which crashes workerd (including | 7 | // isolate-startup cost rationale). `StdioClientTransport` additionally stays |
| 15 | // vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set | 8 | // 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 the | 10 | // 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`. | ||
| 14 | import { loadMcpClientSdk, type McpClientSdk } from "./client-module"; | ||
| 19 | 15 | ||
| 20 | import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types"; | 16 | import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types"; |
| 21 | import { | 17 | import { |
| @@ -203,12 +199,12 @@ const fetchFromHttpClientLayer = ( · 181 unmodified lines | |||
| 203 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The | 199 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The |
| 204 | // cfworker validator does not use code generation and works in every | 200 | // cfworker validator does not use code generation and works in every |
| 205 | // runtime we ship to. | 201 | // runtime we ship to. |
| 206 | const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client => | 202 | const 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 | ||
| 250 | const connectClient = (input: { | 246 | const 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 discovery | 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 | 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, |
| @@ -1,6 +1,13 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { beforeAll, describe, expect, it } from "@effect/vitest"; |
| 2 | import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; | 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 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors | 11 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors |
| 5 | import { insufficientScopeFromCause } from "./http-status"; | 12 | import { insufficientScopeFromCause } from "./http-status"; |
| 6 | 13 | ||
| @@ -10,7 +10,10 @@ · 9 unmodified lines | |||
| 10 | import { Option, Schema } from "effect"; | 10 | import { Option, Schema } from "effect"; |
| 11 | 11 | ||
| 12 | import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; | 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 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); | 18 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); |
| 16 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); | 19 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); |
| @@ -28,8 +31,10 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 11 unmodified lines | |||
| 28 | }); | 31 | }); |
| 29 | 32 | ||
| 30 | const statusFromTypedTransportError = (cause: unknown): number | undefined => { | 33 | const 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 | ||
| 56 | export const insufficientScopeFromCause = (cause: unknown): boolean => | 61 | export 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 }) => |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { beforeAll, describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Predicate } from "effect"; | 2 | import { Effect, Predicate } from "effect"; |
| 3 | import { HttpServerResponse } from "effect/unstable/http"; | 3 | import { HttpServerResponse } from "effect/unstable/http"; |
| 4 | 4 | ||
| @@ -11,7 +11,13 @@ import { · 6 unmodified lines | |||
| 11 | import { ElicitationResponse } from "@executor-js/sdk"; | 11 | import { ElicitationResponse } from "@executor-js/sdk"; |
| 12 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; | 12 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 13 | 13 | ||
| 14 | import { loadMcpClientSdk } from "./client-module"; | ||
| 14 | import { createMcpConnector, type McpConnection, type McpConnector } from "./connection"; | 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 | import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors"; | 21 | import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors"; |
| 16 | import { invokeMcpTool } from "./invoke"; | 22 | import { invokeMcpTool } from "./invoke"; |
| 17 | 23 | ||
| @@ -16,7 +16,11 @@ · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; | 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 | import { | 25 | import { |
| 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 | ||
| 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 | // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass | 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 && | 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 provides | 73 | const 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 through | 87 | // 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 shapes | 217 | 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}`, |
| @@ -2,7 +2,8 @@ import { Effect, Layer, Option, Result, Schema } from "effect"; · 1 unmodified line | |||
| 2 | import type { HttpClient } from "effect/unstable/http"; | 2 | import type { HttpClient } from "effect/unstable/http"; |
| 3 | 3 | ||
| 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/client"; | 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 | import { | 8 | import { |
| 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 | ||
| 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 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { | 409 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { |
| 405 | const defaultStructuredContentSchema = | 410 | const defaultStructuredContentSchema = |