Unified · Split
| @@ -0,0 +1,7 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-mcp": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Stdio MCP servers can negotiate the modern protocol (`versionNegotiation: "auto"`)** | ||
| 6 | |||
| 7 | Spawned stdio MCP integrations previously always opened with the legacy 2025 `initialize` handshake, so an SDK v2 server running with its legacy compatibility lane disabled could not connect. Stdio integrations now accept `versionNegotiation: "auto"` (on `mcp.addServer` and the stored config) to probe `server/discover` per spec 2026-07-28, falling back to `initialize` on legacy servers. The default stays `legacy`: the SDK's stdio probe costs an extra short-lived child process per connect and stalls on silent legacy servers, which is the wrong trade for spawn-per-call CLI servers. The connect handshake span now records the negotiated era (`plugin.mcp.protocol_era`) so integration authors can verify which handshake a connection used. | ||
| @@ -1011,6 +1011,7 @@ · 1010 unmodified lines | |||
| 1011 | "@effect/vitest": "catalog:", | 1011 | "@effect/vitest": "catalog:", |
| 1012 | "@executor-js/api": "workspace:*", | 1012 | "@executor-js/api": "workspace:*", |
| 1013 | "@executor-js/react": "workspace:*", | 1013 | "@executor-js/react": "workspace:*", |
| 1014 | "@modelcontextprotocol/server": "2.0.0", | ||
| 1014 | "@types/node": "catalog:", | 1015 | "@types/node": "catalog:", |
| 1015 | "@types/react": "catalog:", | 1016 | "@types/react": "catalog:", |
| 1016 | "bun-types": "catalog:", | 1017 | "bun-types": "catalog:", |
| @@ -146,6 +146,36 @@ scenario( · 145 unmodified lines | |||
| 146 | declTools.map((t) => t.name), | 146 | declTools.map((t) => t.name), |
| 147 | "connecting with the secret discovers the env-gated tool", | 147 | "connecting with the secret discovers the env-gated tool", |
| 148 | ).toContain("whoami"); | 148 | ).toContain("whoami"); |
| 149 | |||
| 150 | // --- versionNegotiation "auto" survives the API → config → connector | ||
| 151 | // path and still reaches a legacy server: the probe gets the fixture's | ||
| 152 | // method-not-found for `server/discover` (a definitive legacy verdict) | ||
| 153 | // and falls back to `initialize`. Modern-era acceptance against a real | ||
| 154 | // legacy-disabled SDK v2 server lives in the plugin's | ||
| 155 | // stdio-negotiation.test.ts. --- | ||
| 156 | const autoSlug = "e2e-stdio-auto"; | ||
| 157 | yield* client.mcp.addServer({ | ||
| 158 | payload: { | ||
| 159 | transport: "stdio", | ||
| 160 | name: "E2E Stdio Auto", | ||
| 161 | command: "node", | ||
| 162 | args: [FIXTURE], | ||
| 163 | versionNegotiation: "auto", | ||
| 164 | slug: autoSlug, | ||
| 165 | }, | ||
| 166 | }); | ||
| 167 | |||
| 168 | const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); | ||
| 169 | expect( | ||
| 170 | JSON.stringify(autoStored?.config ?? {}), | ||
| 171 | "the negotiation mode is persisted on the integration config", | ||
| 172 | ).toContain('"versionNegotiation":"auto"'); | ||
| 173 | |||
| 174 | const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); | ||
| 175 | expect( | ||
| 176 | autoTools.map((t) => t.name), | ||
| 177 | "auto negotiation falls back to legacy and still discovers tools", | ||
| 178 | ).toContain("echo_tool"); | ||
| 149 | }), | 179 | }), |
| 150 | ); | 180 | ); |
| 151 | }), | 181 | }), |
| @@ -75,6 +75,7 @@ · 74 unmodified lines | |||
| 75 | "@effect/vitest": "catalog:", | 75 | "@effect/vitest": "catalog:", |
| 76 | "@executor-js/api": "workspace:*", | 76 | "@executor-js/api": "workspace:*", |
| 77 | "@executor-js/react": "workspace:*", | 77 | "@executor-js/react": "workspace:*", |
| 78 | "@modelcontextprotocol/server": "2.0.0", | ||
| 78 | "@types/node": "catalog:", | 79 | "@types/node": "catalog:", |
| 79 | "@types/react": "catalog:", | 80 | "@types/react": "catalog:", |
| 80 | "bun-types": "catalog:", | 81 | "bun-types": "catalog:", |
| @@ -57,6 +57,10 @@ const AddStdioServerPayload = Schema.Struct({ · 56 unmodified lines | |||
| 57 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ | 57 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ |
| 58 | env: Schema.optional(StringMap), | 58 | env: Schema.optional(StringMap), |
| 59 | cwd: Schema.optional(Schema.String), | 59 | cwd: Schema.optional(Schema.String), |
| 60 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec | ||
| 61 | * 2026-07-28) for modern-only servers; default is the legacy `initialize` | ||
| 62 | * handshake. */ | ||
| 63 | versionNegotiation: Schema.optional(Schema.Literals(["legacy", "auto"])), | ||
| 60 | slug: Schema.optional(Schema.String), | 64 | slug: Schema.optional(Schema.String), |
| 61 | }); | 65 | }); |
| 62 | 66 | ||
| @@ -39,6 +39,7 @@ const toServerInput = ( · 38 unmodified lines | |||
| 39 | envVars?: readonly string[]; | 39 | envVars?: readonly string[]; |
| 40 | env?: Record<string, string>; | 40 | env?: Record<string, string>; |
| 41 | cwd?: string; | 41 | cwd?: string; |
| 42 | versionNegotiation?: "legacy" | "auto"; | ||
| 42 | slug?: string; | 43 | slug?: string; |
| 43 | }; | 44 | }; |
| 44 | return { | 45 | return { |
| @@ -50,6 +51,7 @@ const toServerInput = ( · 5 unmodified lines | |||
| 50 | envVars: p.envVars ? [...p.envVars] : undefined, | 51 | envVars: p.envVars ? [...p.envVars] : undefined, |
| 51 | env: p.env, | 52 | env: p.env, |
| 52 | cwd: p.cwd, | 53 | cwd: p.cwd, |
| 54 | versionNegotiation: p.versionNegotiation, | ||
| 53 | slug: p.slug, | 55 | slug: p.slug, |
| 54 | }; | 56 | }; |
| 55 | } | 57 | } |
| @@ -261,6 +261,15 @@ const connectClient = (input: { · 260 unmodified lines | |||
| 261 | catch: (cause) => | 261 | catch: (cause) => |
| 262 | connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), | 262 | connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), |
| 263 | }).pipe( | 263 | }).pipe( |
| 264 | // The negotiated era ("modern" = 2026-07-28 server/discover, "legacy" = | ||
| 265 | // 2025 initialize) is otherwise invisible: both eras list and call tools | ||
| 266 | // identically, so traces are the one place an integration author can | ||
| 267 | // verify which handshake a connection actually used. | ||
| 268 | Effect.tap(() => | ||
| 269 | Effect.annotateCurrentSpan({ | ||
| 270 | "plugin.mcp.protocol_era": client.getProtocolEra() ?? "unknown", | ||
| 271 | }), | ||
| 272 | ), | ||
| 264 | Effect.withSpan("plugin.mcp.connection.handshake", { | 273 | Effect.withSpan("plugin.mcp.connection.handshake", { |
| 265 | attributes: { "plugin.mcp.transport": input.transport }, | 274 | attributes: { "plugin.mcp.transport": input.transport }, |
| 266 | }), | 275 | }), |
| @@ -299,6 +308,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 32 unmodified lines | |||
| 299 | 308 | ||
| 300 | return yield* connectClient({ | 309 | return yield* connectClient({ |
| 301 | transport: "stdio", | 310 | transport: "stdio", |
| 311 | // Opt-in per integration (default legacy) — see | ||
| 312 | // `McpStdioVersionNegotiation` for why stdio does not follow the | ||
| 313 | // remote transport's unconditional auto. | ||
| 314 | ...(input.versionNegotiation === "auto" | ||
| 315 | ? { versionNegotiation: { mode: "auto" as const } } | ||
| 316 | : {}), | ||
| 302 | createTransport: () => | 317 | createTransport: () => |
| 303 | createStdioTransport({ | 318 | createStdioTransport({ |
| 304 | command, | 319 | command, |
| @@ -318,9 +333,10 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 13 unmodified lines | |||
| 318 | 333 | ||
| 319 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); | 334 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); |
| 320 | 335 | ||
| 321 | // Auto-negotiate the 2026-07-28 era only on Streamable HTTP. SSE is a | 336 | // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable |
| 322 | // legacy-only transport, and stdio servers are spawned per call where the | 337 | // HTTP. SSE is a legacy-only transport; stdio negotiates per the |
| 323 | // SDK recommends retaining its legacy-default handshake. | 338 | // integration's `versionNegotiation` (default legacy — see the stdio |
| 339 | // branch above). | ||
| 324 | const connectStreamableHttp = connectClient({ | 340 | const connectStreamableHttp = connectClient({ |
| 325 | transport: "streamable-http", | 341 | transport: "streamable-http", |
| 326 | versionNegotiation: { mode: "auto" }, | 342 | versionNegotiation: { mode: "auto" }, |
| @@ -60,6 +60,7 @@ import { · 59 unmodified lines | |||
| 60 | expandMcpAuthMethodInputs, | 60 | expandMcpAuthMethodInputs, |
| 61 | mcpAuthMethodFromShorthand, | 61 | mcpAuthMethodFromShorthand, |
| 62 | normalizeMcpAuthMethods, | 62 | normalizeMcpAuthMethods, |
| 63 | McpStdioVersionNegotiation, | ||
| 63 | parseMcpIntegrationConfig, | 64 | parseMcpIntegrationConfig, |
| 64 | type McpIntegrationConfig as McpIntegrationConfigType, | 65 | type McpIntegrationConfig as McpIntegrationConfigType, |
| 65 | type McpStdioEnvMethod, | 66 | type McpStdioEnvMethod, |
| @@ -206,6 +207,11 @@ const McpStdioServerInputSchema = Schema.Struct({ · 140 unmodified lines | |||
| 206 | * instead and leaves the values to the connect step. */ | 207 | * instead and leaves the values to the connect step. */ |
| 207 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 208 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 208 | cwd: Schema.optional(Schema.String), | 209 | cwd: Schema.optional(Schema.String), |
| 210 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec | ||
| 211 | * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize` | ||
| 212 | * handshake — the right call for spawn-per-call servers, where the auto | ||
| 213 | * probe costs an extra child process per connect. */ | ||
| 214 | versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | ||
| 209 | slug: Schema.optional(Schema.String), | 215 | slug: Schema.optional(Schema.String), |
| 210 | }); | 216 | }); |
| 211 | 217 | ||
| @@ -369,6 +375,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 157 unmodified lines | |||
| 369 | command: input.command, | 375 | command: input.command, |
| 370 | args: input.args ? [...input.args] : undefined, | 376 | args: input.args ? [...input.args] : undefined, |
| 371 | cwd: input.cwd, | 377 | cwd: input.cwd, |
| 378 | versionNegotiation: input.versionNegotiation, | ||
| 372 | authenticationTemplate: | 379 | authenticationTemplate: |
| 373 | vars.length > 0 | 380 | vars.length > 0 |
| 374 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] | 381 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] |
| @@ -587,6 +594,7 @@ const buildConnectorInput = ( · 212 unmodified lines | |||
| 587 | args: config.args, | 594 | args: config.args, |
| 588 | env: Object.keys(env).length > 0 ? env : undefined, | 595 | env: Object.keys(env).length > 0 ? env : undefined, |
| 589 | cwd: config.cwd, | 596 | cwd: config.cwd, |
| 597 | versionNegotiation: config.versionNegotiation, | ||
| 590 | } satisfies McpStdioIntegrationConfig); | 598 | } satisfies McpStdioIntegrationConfig); |
| 591 | } | 599 | } |
| 592 | 600 | ||
| @@ -1045,6 +1053,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 452 unmodified lines | |||
| 1045 | command: config.command, | 1053 | command: config.command, |
| 1046 | args: config.args, | 1054 | args: config.args, |
| 1047 | cwd: config.cwd, | 1055 | cwd: config.cwd, |
| 1056 | versionNegotiation: config.versionNegotiation, | ||
| 1048 | authenticationTemplate: hasEnv | 1057 | authenticationTemplate: hasEnv |
| 1049 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] | 1058 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] |
| 1050 | : [{ slug: "none", kind: "none" }], | 1059 | : [{ slug: "none", kind: "none" }], |
| @@ -0,0 +1,24 @@ | |||
| 1 | // Stdio MCP fixture for stdio-negotiation.test.ts, spawned as a child process | ||
| 2 | // with `bun run <this file>`. Serves both protocol eras by default; | ||
| 3 | // `--legacy-reject` refuses the 2025 `initialize` opening so only a client | ||
| 4 | // probing `server/discover` (spec 2026-07-28) can connect — the shape of an | ||
| 5 | // SDK v2 server running with its legacy compatibility lane disabled. | ||
| 6 | import { McpServer } from "@modelcontextprotocol/server"; | ||
| 7 | import { serveStdio } from "@modelcontextprotocol/server/stdio"; | ||
| 8 | import * as z from "zod/v4"; | ||
| 9 | |||
| 10 | serveStdio( | ||
| 11 | () => { | ||
| 12 | const server = new McpServer( | ||
| 13 | { name: "stdio-negotiation-fixture", version: "1.0.0" }, | ||
| 14 | { capabilities: { tools: {} } }, | ||
| 15 | ); | ||
| 16 | server.registerTool( | ||
| 17 | "add", | ||
| 18 | { description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }) }, | ||
| 19 | async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }), | ||
| 20 | ); | ||
| 21 | return server; | ||
| 22 | }, | ||
| 23 | { legacy: process.argv.includes("--legacy-reject") ? "reject" : "serve" }, | ||
| 24 | ); | ||
| @@ -0,0 +1,64 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Predicate } from "effect"; | ||
| 3 | import { fileURLToPath } from "node:url"; | ||
| 4 | |||
| 5 | import { createMcpConnector, type StdioConnectorInput } from "./connection"; | ||
| 6 | |||
| 7 | const fixture = fileURLToPath(new URL("./stdio-negotiation-test-server.ts", import.meta.url)); | ||
| 8 | |||
| 9 | const stdioInput = ( | ||
| 10 | overrides: Partial<Omit<StdioConnectorInput, "transport" | "command">> & { | ||
| 11 | readonly args: readonly string[]; | ||
| 12 | }, | ||
| 13 | ): StdioConnectorInput => ({ | ||
| 14 | transport: "stdio", | ||
| 15 | command: "bun", | ||
| 16 | ...overrides, | ||
| 17 | }); | ||
| 18 | |||
| 19 | const withConnection = (input: StdioConnectorInput) => | ||
| 20 | Effect.acquireRelease(createMcpConnector(input).pipe(Effect.orDie), (connection) => | ||
| 21 | Effect.promise(connection.close), | ||
| 22 | ); | ||
| 23 | |||
| 24 | describe("stdio version negotiation", () => { | ||
| 25 | it.effect("auto negotiation connects modern to a server with legacy support disabled", () => | ||
| 26 | Effect.scoped( | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const connection = yield* withConnection( | ||
| 29 | stdioInput({ args: ["run", fixture, "--legacy-reject"], versionNegotiation: "auto" }), | ||
| 30 | ); | ||
| 31 | |||
| 32 | expect(connection.client.getProtocolEra()).toBe("modern"); | ||
| 33 | const tools = yield* Effect.promise(() => connection.client.listTools()); | ||
| 34 | expect(tools.tools.map(({ name }) => name)).toContain("add"); | ||
| 35 | const result = yield* Effect.promise(() => | ||
| 36 | connection.client.callTool({ name: "add", arguments: { a: 2, b: 2 } }), | ||
| 37 | ); | ||
| 38 | expect(result.content).toEqual([{ type: "text", text: "4" }]); | ||
| 39 | }), | ||
| 40 | ), | ||
| 41 | ); | ||
| 42 | |||
| 43 | it.effect("the default handshake surfaces a connection error on that same server", () => | ||
| 44 | Effect.gen(function* () { | ||
| 45 | const error = yield* createMcpConnector( | ||
| 46 | stdioInput({ args: ["run", fixture, "--legacy-reject"] }), | ||
| 47 | ).pipe(Effect.flip); | ||
| 48 | |||
| 49 | expect(Predicate.isTagged(error, "McpConnectionError")).toBe(true); | ||
| 50 | }), | ||
| 51 | ); | ||
| 52 | |||
| 53 | it.effect("absent config keeps the legacy handshake against a both-era server", () => | ||
| 54 | Effect.scoped( | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const connection = yield* withConnection(stdioInput({ args: ["run", fixture] })); | ||
| 57 | |||
| 58 | expect(connection.client.getProtocolEra()).toBe("legacy"); | ||
| 59 | const tools = yield* Effect.promise(() => connection.client.listTools()); | ||
| 60 | expect(tools.tools.map(({ name }) => name)).toContain("add"); | ||
| 61 | }), | ||
| 62 | ), | ||
| 63 | ); | ||
| 64 | }); | ||
| @@ -31,6 +31,20 @@ export type McpRemoteTransport = typeof McpRemoteTransport.Type; · 30 unmodified lines | |||
| 31 | export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); | 31 | export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); |
| 32 | export type McpTransport = typeof McpTransport.Type; | 32 | export type McpTransport = typeof McpTransport.Type; |
| 33 | 33 | ||
| 34 | /** Protocol-version negotiation for a stdio server, mirroring the client | ||
| 35 | * SDK's `versionNegotiation` modes. `legacy` (the default when absent) opens | ||
| 36 | * with the 2025 `initialize` handshake; `auto` probes `server/discover` | ||
| 37 | * (spec 2026-07-28) and falls back to `initialize` on legacy servers. | ||
| 38 | * | ||
| 39 | * Deliberately opt-in, unlike remote streamable HTTP where `auto` is | ||
| 40 | * unconditional: the SDK's stdio probe runs on a short-lived sibling | ||
| 41 | * process, and a legacy server that never answers the probe stalls connect | ||
| 42 | * for the full probe timeout — the wrong default for spawn-per-call CLI | ||
| 43 | * servers, and exactly the case the SDK's own guidance says to keep on the | ||
| 44 | * legacy handshake unless the server is known-modern. */ | ||
| 45 | export const McpStdioVersionNegotiation = Schema.Literals(["legacy", "auto"]); | ||
| 46 | export type McpStdioVersionNegotiation = typeof McpStdioVersionNegotiation.Type; | ||
| 47 | |||
| 34 | // --------------------------------------------------------------------------- | 48 | // --------------------------------------------------------------------------- |
| 35 | // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) | 49 | // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) |
| 36 | // plus MCP's own oauth variant. An integration declares zero or more methods, | 50 | // plus MCP's own oauth variant. An integration declares zero or more methods, |
| @@ -208,6 +222,9 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 171 unmodified lines | |||
| 208 | env: Schema.optional(StringMap), | 222 | env: Schema.optional(StringMap), |
| 209 | /** Working directory */ | 223 | /** Working directory */ |
| 210 | cwd: Schema.optional(Schema.String), | 224 | cwd: Schema.optional(Schema.String), |
| 225 | /** Protocol negotiation at connect. Absent means `legacy` (see | ||
| 226 | * `McpStdioVersionNegotiation` for why that stays the default). */ | ||
| 227 | versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | ||
| 211 | /** Declared auth methods — a single `stdio_env` method naming the secret env | 228 | /** Declared auth methods — a single `stdio_env` method naming the secret env |
| 212 | * vars, or `none`. A connection's `template` picks one by slug, exactly as | 229 | * vars, or `none`. A connection's `template` picks one by slug, exactly as |
| 213 | * for remote servers. Optional so pre-revamp stdio configs (which had no | 230 | * for remote servers. Optional so pre-revamp stdio configs (which had no |