diff --git a/.changeset/stdio-modern-negotiation.md b/.changeset/stdio-modern-negotiation.md new file mode 100644 index 0000000000..4c5861de9a --- /dev/null +++ b/.changeset/stdio-modern-negotiation.md @@ -0,0 +1,7 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**Stdio MCP servers can negotiate the modern protocol (`versionNegotiation: "auto"`)** + +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. diff --git a/bun.lock b/bun.lock index a4266d315b..f0c9b7ae92 100644 --- a/bun.lock +++ b/bun.lock @@ -1011,6 +1011,7 @@ "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", + "@modelcontextprotocol/server": "2.0.0", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", diff --git a/e2e/local/stdio-mcp.test.ts b/e2e/local/stdio-mcp.test.ts index f890122592..a8ece4caa3 100644 --- a/e2e/local/stdio-mcp.test.ts +++ b/e2e/local/stdio-mcp.test.ts @@ -146,6 +146,36 @@ scenario( declTools.map((t) => t.name), "connecting with the secret discovers the env-gated tool", ).toContain("whoami"); + + // --- versionNegotiation "auto" survives the API → config → connector + // path and still reaches a legacy server: the probe gets the fixture's + // method-not-found for `server/discover` (a definitive legacy verdict) + // and falls back to `initialize`. Modern-era acceptance against a real + // legacy-disabled SDK v2 server lives in the plugin's + // stdio-negotiation.test.ts. --- + const autoSlug = "e2e-stdio-auto"; + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio Auto", + command: "node", + args: [FIXTURE], + versionNegotiation: "auto", + slug: autoSlug, + }, + }); + + const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); + expect( + JSON.stringify(autoStored?.config ?? {}), + "the negotiation mode is persisted on the integration config", + ).toContain('"versionNegotiation":"auto"'); + + const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); + expect( + autoTools.map((t) => t.name), + "auto negotiation falls back to legacy and still discovers tools", + ).toContain("echo_tool"); }), ); }), diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 13451d29bc..af9334a4ac 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -75,6 +75,7 @@ "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", + "@modelcontextprotocol/server": "2.0.0", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 324b9d6841..2de2fb1222 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -57,6 +57,10 @@ const AddStdioServerPayload = Schema.Struct({ /** One-shot secret env values (programmatic). The UI sends `envVars`. */ env: Schema.optional(StringMap), cwd: Schema.optional(Schema.String), + /** Protocol negotiation at connect: `auto` probes `server/discover` (spec + * 2026-07-28) for modern-only servers; default is the legacy `initialize` + * handshake. */ + versionNegotiation: Schema.optional(Schema.Literals(["legacy", "auto"])), slug: Schema.optional(Schema.String), }); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 6ca5c3188a..2b05275ad3 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -39,6 +39,7 @@ const toServerInput = ( envVars?: readonly string[]; env?: Record; cwd?: string; + versionNegotiation?: "legacy" | "auto"; slug?: string; }; return { @@ -50,6 +51,7 @@ const toServerInput = ( envVars: p.envVars ? [...p.envVars] : undefined, env: p.env, cwd: p.cwd, + versionNegotiation: p.versionNegotiation, slug: p.slug, }; } diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 22fb1647cd..ad9eabbf36 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -261,6 +261,15 @@ const connectClient = (input: { catch: (cause) => connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), }).pipe( + // The negotiated era ("modern" = 2026-07-28 server/discover, "legacy" = + // 2025 initialize) is otherwise invisible: both eras list and call tools + // identically, so traces are the one place an integration author can + // verify which handshake a connection actually used. + Effect.tap(() => + Effect.annotateCurrentSpan({ + "plugin.mcp.protocol_era": client.getProtocolEra() ?? "unknown", + }), + ), Effect.withSpan("plugin.mcp.connection.handshake", { attributes: { "plugin.mcp.transport": input.transport }, }), @@ -299,6 +308,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { return yield* connectClient({ transport: "stdio", + // Opt-in per integration (default legacy) — see + // `McpStdioVersionNegotiation` for why stdio does not follow the + // remote transport's unconditional auto. + ...(input.versionNegotiation === "auto" + ? { versionNegotiation: { mode: "auto" as const } } + : {}), createTransport: () => createStdioTransport({ command, @@ -318,9 +333,10 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); - // Auto-negotiate the 2026-07-28 era only on Streamable HTTP. SSE is a - // legacy-only transport, and stdio servers are spawned per call where the - // SDK recommends retaining its legacy-default handshake. + // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable + // HTTP. SSE is a legacy-only transport; stdio negotiates per the + // integration's `versionNegotiation` (default legacy — see the stdio + // branch above). const connectStreamableHttp = connectClient({ transport: "streamable-http", versionNegotiation: { mode: "auto" }, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 70ec0e69e6..af82b0b278 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -60,6 +60,7 @@ import { expandMcpAuthMethodInputs, mcpAuthMethodFromShorthand, normalizeMcpAuthMethods, + McpStdioVersionNegotiation, parseMcpIntegrationConfig, type McpIntegrationConfig as McpIntegrationConfigType, type McpStdioEnvMethod, @@ -206,6 +207,11 @@ const McpStdioServerInputSchema = Schema.Struct({ * instead and leaves the values to the connect step. */ env: Schema.optional(Schema.Record(Schema.String, Schema.String)), cwd: Schema.optional(Schema.String), + /** Protocol negotiation at connect: `auto` probes `server/discover` (spec + * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize` + * handshake — the right call for spawn-per-call servers, where the auto + * probe costs an extra child process per connect. */ + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), slug: Schema.optional(Schema.String), }); @@ -369,6 +375,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => command: input.command, args: input.args ? [...input.args] : undefined, cwd: input.cwd, + versionNegotiation: input.versionNegotiation, authenticationTemplate: vars.length > 0 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] @@ -587,6 +594,7 @@ const buildConnectorInput = ( args: config.args, env: Object.keys(env).length > 0 ? env : undefined, cwd: config.cwd, + versionNegotiation: config.versionNegotiation, } satisfies McpStdioIntegrationConfig); } @@ -1045,6 +1053,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { command: config.command, args: config.args, cwd: config.cwd, + versionNegotiation: config.versionNegotiation, authenticationTemplate: hasEnv ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] : [{ slug: "none", kind: "none" }], diff --git a/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts b/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts new file mode 100644 index 0000000000..c31bd2e29a --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts @@ -0,0 +1,24 @@ +// Stdio MCP fixture for stdio-negotiation.test.ts, spawned as a child process +// with `bun run `. Serves both protocol eras by default; +// `--legacy-reject` refuses the 2025 `initialize` opening so only a client +// probing `server/discover` (spec 2026-07-28) can connect — the shape of an +// SDK v2 server running with its legacy compatibility lane disabled. +import { McpServer } from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import * as z from "zod/v4"; + +serveStdio( + () => { + const server = new McpServer( + { name: "stdio-negotiation-fixture", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + server.registerTool( + "add", + { description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }) }, + async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }), + ); + return server; + }, + { legacy: process.argv.includes("--legacy-reject") ? "reject" : "serve" }, +); diff --git a/packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts b/packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts new file mode 100644 index 0000000000..d3400dc8a9 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; +import { fileURLToPath } from "node:url"; + +import { createMcpConnector, type StdioConnectorInput } from "./connection"; + +const fixture = fileURLToPath(new URL("./stdio-negotiation-test-server.ts", import.meta.url)); + +const stdioInput = ( + overrides: Partial> & { + readonly args: readonly string[]; + }, +): StdioConnectorInput => ({ + transport: "stdio", + command: "bun", + ...overrides, +}); + +const withConnection = (input: StdioConnectorInput) => + Effect.acquireRelease(createMcpConnector(input).pipe(Effect.orDie), (connection) => + Effect.promise(connection.close), + ); + +describe("stdio version negotiation", () => { + it.effect("auto negotiation connects modern to a server with legacy support disabled", () => + Effect.scoped( + Effect.gen(function* () { + const connection = yield* withConnection( + stdioInput({ args: ["run", fixture, "--legacy-reject"], versionNegotiation: "auto" }), + ); + + expect(connection.client.getProtocolEra()).toBe("modern"); + const tools = yield* Effect.promise(() => connection.client.listTools()); + expect(tools.tools.map(({ name }) => name)).toContain("add"); + const result = yield* Effect.promise(() => + connection.client.callTool({ name: "add", arguments: { a: 2, b: 2 } }), + ); + expect(result.content).toEqual([{ type: "text", text: "4" }]); + }), + ), + ); + + it.effect("the default handshake surfaces a connection error on that same server", () => + Effect.gen(function* () { + const error = yield* createMcpConnector( + stdioInput({ args: ["run", fixture, "--legacy-reject"] }), + ).pipe(Effect.flip); + + expect(Predicate.isTagged(error, "McpConnectionError")).toBe(true); + }), + ); + + it.effect("absent config keeps the legacy handshake against a both-era server", () => + Effect.scoped( + Effect.gen(function* () { + const connection = yield* withConnection(stdioInput({ args: ["run", fixture] })); + + expect(connection.client.getProtocolEra()).toBe("legacy"); + const tools = yield* Effect.promise(() => connection.client.listTools()); + expect(tools.tools.map(({ name }) => name)).toContain("add"); + }), + ), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 838e81e0ac..a9ebcd427f 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -31,6 +31,20 @@ export type McpRemoteTransport = typeof McpRemoteTransport.Type; export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); export type McpTransport = typeof McpTransport.Type; +/** Protocol-version negotiation for a stdio server, mirroring the client + * SDK's `versionNegotiation` modes. `legacy` (the default when absent) opens + * with the 2025 `initialize` handshake; `auto` probes `server/discover` + * (spec 2026-07-28) and falls back to `initialize` on legacy servers. + * + * Deliberately opt-in, unlike remote streamable HTTP where `auto` is + * unconditional: the SDK's stdio probe runs on a short-lived sibling + * process, and a legacy server that never answers the probe stalls connect + * for the full probe timeout — the wrong default for spawn-per-call CLI + * servers, and exactly the case the SDK's own guidance says to keep on the + * legacy handshake unless the server is known-modern. */ +export const McpStdioVersionNegotiation = Schema.Literals(["legacy", "auto"]); +export type McpStdioVersionNegotiation = typeof McpStdioVersionNegotiation.Type; + // --------------------------------------------------------------------------- // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) // plus MCP's own oauth variant. An integration declares zero or more methods, @@ -208,6 +222,9 @@ export const McpStdioIntegrationConfig = Schema.Struct({ env: Schema.optional(StringMap), /** Working directory */ cwd: Schema.optional(Schema.String), + /** Protocol negotiation at connect. Absent means `legacy` (see + * `McpStdioVersionNegotiation` for why that stays the default). */ + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), /** Declared auth methods — a single `stdio_env` method naming the secret env * vars, or `none`. A connection's `template` picks one by slug, exactly as * for remote servers. Optional so pre-revamp stdio configs (which had no