Skip to content

Commit 9ecc7cb

Browse files
authored
Opt-in modern protocol negotiation for stdio MCP servers (#1646)
1 parent b18e8cf commit 9ecc7cb

11 files changed

Lines changed: 178 additions & 3 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/local/stdio-mcp.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,36 @@ scenario(
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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ const AddStdioServerPayload = Schema.Struct({
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const toServerInput = (
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 = (
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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,15 @@ const connectClient = (input: {
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 => {
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 => {
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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
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({
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 =>
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 = (
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) => {
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" }],
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)