History

Opt-in modern protocol negotiation for stdio MCP servers

Rhys Sullivan committed 2 months ago

Commit 415e5be1 parent b18e8cf11 files · +178 −3Browse files

Unified · Split

11 files
  1. .changeset/stdio-modern-negotiation.md +7 −0
  2. bun.lock +1 −0
  3. e2e/local/stdio-mcp.test.ts +30 −0
  4. packages/plugins/mcp/package.json +1 −0
  5. packages/plugins/mcp/src/api/group.ts +4 −0
  6. packages/plugins/mcp/src/api/handlers.ts +2 −0
  7. packages/plugins/mcp/src/sdk/connection.ts +19 −3
  8. packages/plugins/mcp/src/sdk/plugin.ts +9 −0
  9. packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts +24 −0
  10. packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts +64 −0
  11. packages/plugins/mcp/src/sdk/types.ts +17 −0
.changeset/stdio-modern-negotiation.md+7−0
@@ -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.
bun.lock+1−0
@@ -1011,6 +1011,7 @@ · 1010 unmodified lines
10111011 "@effect/vitest": "catalog:",
10121012 "@executor-js/api": "workspace:*",
10131013 "@executor-js/react": "workspace:*",
1014+ "@modelcontextprotocol/server": "2.0.0",
10141015 "@types/node": "catalog:",
10151016 "@types/react": "catalog:",
10161017 "bun-types": "catalog:",
e2e/local/stdio-mcp.test.ts+30−0
@@ -146,6 +146,36 @@ scenario( · 145 unmodified lines
146146 declTools.map((t) => t.name),
147147 "connecting with the secret discovers the env-gated tool",
148148 ).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");
149179 }),
150180 );
151181 }),
packages/plugins/mcp/package.json+1−0
@@ -75,6 +75,7 @@ · 74 unmodified lines
7575 "@effect/vitest": "catalog:",
7676 "@executor-js/api": "workspace:*",
7777 "@executor-js/react": "workspace:*",
78+ "@modelcontextprotocol/server": "2.0.0",
7879 "@types/node": "catalog:",
7980 "@types/react": "catalog:",
8081 "bun-types": "catalog:",
packages/plugins/mcp/src/api/group.ts+4−0
@@ -57,6 +57,10 @@ const AddStdioServerPayload = Schema.Struct({ · 56 unmodified lines
5757 /** One-shot secret env values (programmatic). The UI sends `envVars`. */
5858 env: Schema.optional(StringMap),
5959 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"])),
6064 slug: Schema.optional(Schema.String),
6165 });
6266
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -39,6 +39,7 @@ const toServerInput = ( · 38 unmodified lines
3939 envVars?: readonly string[];
4040 env?: Record<string, string>;
4141 cwd?: string;
42+ versionNegotiation?: "legacy" | "auto";
4243 slug?: string;
4344 };
4445 return {
@@ -50,6 +51,7 @@ const toServerInput = ( · 5 unmodified lines
5051 envVars: p.envVars ? [...p.envVars] : undefined,
5152 env: p.env,
5253 cwd: p.cwd,
54+ versionNegotiation: p.versionNegotiation,
5355 slug: p.slug,
5456 };
5557 }
packages/plugins/mcp/src/sdk/connection.ts+19−3
@@ -261,6 +261,15 @@ const connectClient = (input: { · 260 unmodified lines
261261 catch: (cause) =>
262262 connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause),
263263 }).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+ ),
264273 Effect.withSpan("plugin.mcp.connection.handshake", {
265274 attributes: { "plugin.mcp.transport": input.transport },
266275 }),
@@ -299,6 +308,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 32 unmodified lines
299308
300309 return yield* connectClient({
301310 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+ : {}),
302317 createTransport: () =>
303318 createStdioTransport({
304319 command,
@@ -318,9 +333,10 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 13 unmodified lines
318333
319334 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
320335
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).
324340 const connectStreamableHttp = connectClient({
325341 transport: "streamable-http",
326342 versionNegotiation: { mode: "auto" },
packages/plugins/mcp/src/sdk/plugin.ts+9−0
@@ -60,6 +60,7 @@ import { · 59 unmodified lines
6060 expandMcpAuthMethodInputs,
6161 mcpAuthMethodFromShorthand,
6262 normalizeMcpAuthMethods,
63+ McpStdioVersionNegotiation,
6364 parseMcpIntegrationConfig,
6465 type McpIntegrationConfig as McpIntegrationConfigType,
6566 type McpStdioEnvMethod,
@@ -206,6 +207,11 @@ const McpStdioServerInputSchema = Schema.Struct({ · 140 unmodified lines
206207 * instead and leaves the values to the connect step. */
207208 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
208209 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),
209215 slug: Schema.optional(Schema.String),
210216 });
211217
@@ -369,6 +375,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 157 unmodified lines
369375 command: input.command,
370376 args: input.args ? [...input.args] : undefined,
371377 cwd: input.cwd,
378+ versionNegotiation: input.versionNegotiation,
372379 authenticationTemplate:
373380 vars.length > 0
374381 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }]
@@ -587,6 +594,7 @@ const buildConnectorInput = ( · 212 unmodified lines
587594 args: config.args,
588595 env: Object.keys(env).length > 0 ? env : undefined,
589596 cwd: config.cwd,
597+ versionNegotiation: config.versionNegotiation,
590598 } satisfies McpStdioIntegrationConfig);
591599 }
592600
@@ -1045,6 +1053,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 452 unmodified lines
10451053 command: config.command,
10461054 args: config.args,
10471055 cwd: config.cwd,
1056+ versionNegotiation: config.versionNegotiation,
10481057 authenticationTemplate: hasEnv
10491058 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }]
10501059 : [{ slug: "none", kind: "none" }],
packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts+24−0
@@ -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+);
packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts+64−0
@@ -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+});
packages/plugins/mcp/src/sdk/types.ts+17−0
@@ -31,6 +31,20 @@ export type McpRemoteTransport = typeof McpRemoteTransport.Type; · 30 unmodified lines
3131 export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]);
3232 export type McpTransport = typeof McpTransport.Type;
3333
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+
3448 // ---------------------------------------------------------------------------
3549 // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`)
3650 // 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
208222 env: Schema.optional(StringMap),
209223 /** Working directory */
210224 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),
211228 /** Declared auth methods — a single `stdio_env` method naming the secret env
212229 * vars, or `none`. A connection's `template` picks one by slug, exactly as
213230 * for remote servers. Optional so pre-revamp stdio configs (which had no