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 | 1011 | "@effect/vitest": "catalog:", |
| 1012 | 1012 | "@executor-js/api": "workspace:*", |
| 1013 | 1013 | "@executor-js/react": "workspace:*", |
| 1014 | + "@modelcontextprotocol/server": "2.0.0", | |
| 1014 | 1015 | "@types/node": "catalog:", |
| 1015 | 1016 | "@types/react": "catalog:", |
| 1016 | 1017 | "bun-types": "catalog:", |
| @@ -146,6 +146,36 @@ scenario( · 145 unmodified lines | ||
| 146 | 146 | declTools.map((t) => t.name), |
| 147 | 147 | "connecting with the secret discovers the env-gated tool", |
| 148 | 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 | 75 | "@effect/vitest": "catalog:", |
| 76 | 76 | "@executor-js/api": "workspace:*", |
| 77 | 77 | "@executor-js/react": "workspace:*", |
| 78 | + "@modelcontextprotocol/server": "2.0.0", | |
| 78 | 79 | "@types/node": "catalog:", |
| 79 | 80 | "@types/react": "catalog:", |
| 80 | 81 | "bun-types": "catalog:", |
| @@ -57,6 +57,10 @@ const AddStdioServerPayload = Schema.Struct({ · 56 unmodified lines | ||
| 57 | 57 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ |
| 58 | 58 | env: Schema.optional(StringMap), |
| 59 | 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 | 64 | slug: Schema.optional(Schema.String), |
| 61 | 65 | }); |
| 62 | 66 | |
| @@ -39,6 +39,7 @@ const toServerInput = ( · 38 unmodified lines | ||
| 39 | 39 | envVars?: readonly string[]; |
| 40 | 40 | env?: Record<string, string>; |
| 41 | 41 | cwd?: string; |
| 42 | + versionNegotiation?: "legacy" | "auto"; | |
| 42 | 43 | slug?: string; |
| 43 | 44 | }; |
| 44 | 45 | return { |
| @@ -50,6 +51,7 @@ const toServerInput = ( · 5 unmodified lines | ||
| 50 | 51 | envVars: p.envVars ? [...p.envVars] : undefined, |
| 51 | 52 | env: p.env, |
| 52 | 53 | cwd: p.cwd, |
| 54 | + versionNegotiation: p.versionNegotiation, | |
| 53 | 55 | slug: p.slug, |
| 54 | 56 | }; |
| 55 | 57 | } |
| @@ -261,6 +261,15 @@ const connectClient = (input: { · 260 unmodified lines | ||
| 261 | 261 | catch: (cause) => |
| 262 | 262 | connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), |
| 263 | 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 | 273 | Effect.withSpan("plugin.mcp.connection.handshake", { |
| 265 | 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 | 309 | return yield* connectClient({ |
| 301 | 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 | 317 | createTransport: () => |
| 303 | 318 | createStdioTransport({ |
| 304 | 319 | command, |
| @@ -318,9 +333,10 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 13 unmodified lines | ||
| 318 | 333 | |
| 319 | 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 | |
| 322 | − // legacy-only transport, and stdio servers are spawned per call where the | |
| 323 | − // SDK recommends retaining its legacy-default handshake. | |
| 336 | + // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable | |
| 337 | + // HTTP. SSE is a legacy-only transport; stdio negotiates per the | |
| 338 | + // integration's `versionNegotiation` (default legacy — see the stdio | |
| 339 | + // branch above). | |
| 324 | 340 | const connectStreamableHttp = connectClient({ |
| 325 | 341 | transport: "streamable-http", |
| 326 | 342 | versionNegotiation: { mode: "auto" }, |
| @@ -60,6 +60,7 @@ import { · 59 unmodified lines | ||
| 60 | 60 | expandMcpAuthMethodInputs, |
| 61 | 61 | mcpAuthMethodFromShorthand, |
| 62 | 62 | normalizeMcpAuthMethods, |
| 63 | + McpStdioVersionNegotiation, | |
| 63 | 64 | parseMcpIntegrationConfig, |
| 64 | 65 | type McpIntegrationConfig as McpIntegrationConfigType, |
| 65 | 66 | type McpStdioEnvMethod, |
| @@ -206,6 +207,11 @@ const McpStdioServerInputSchema = Schema.Struct({ · 140 unmodified lines | ||
| 206 | 207 | * instead and leaves the values to the connect step. */ |
| 207 | 208 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 208 | 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 | 215 | slug: Schema.optional(Schema.String), |
| 210 | 216 | }); |
| 211 | 217 | |
| @@ -369,6 +375,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 157 unmodified lines | ||
| 369 | 375 | command: input.command, |
| 370 | 376 | args: input.args ? [...input.args] : undefined, |
| 371 | 377 | cwd: input.cwd, |
| 378 | + versionNegotiation: input.versionNegotiation, | |
| 372 | 379 | authenticationTemplate: |
| 373 | 380 | vars.length > 0 |
| 374 | 381 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] |
| @@ -587,6 +594,7 @@ const buildConnectorInput = ( · 212 unmodified lines | ||
| 587 | 594 | args: config.args, |
| 588 | 595 | env: Object.keys(env).length > 0 ? env : undefined, |
| 589 | 596 | cwd: config.cwd, |
| 597 | + versionNegotiation: config.versionNegotiation, | |
| 590 | 598 | } satisfies McpStdioIntegrationConfig); |
| 591 | 599 | } |
| 592 | 600 | |
| @@ -1045,6 +1053,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 452 unmodified lines | ||
| 1045 | 1053 | command: config.command, |
| 1046 | 1054 | args: config.args, |
| 1047 | 1055 | cwd: config.cwd, |
| 1056 | + versionNegotiation: config.versionNegotiation, | |
| 1048 | 1057 | authenticationTemplate: hasEnv |
| 1049 | 1058 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] |
| 1050 | 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 | 31 | export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); |
| 32 | 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 | 49 | // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) |
| 36 | 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 | 222 | env: Schema.optional(StringMap), |
| 209 | 223 | /** Working directory */ |
| 210 | 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 | 228 | /** Declared auth methods — a single `stdio_env` method naming the secret env |
| 212 | 229 | * vars, or `none`. A connection's `template` picks one by slug, exactly as |
| 213 | 230 | * for remote servers. Optional so pre-revamp stdio configs (which had no |