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
7Spawned 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
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:",
e2e/local/stdio-mcp.test.ts+30−0
@@ -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 }),
packages/plugins/mcp/package.json+1−0
@@ -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:",
packages/plugins/mcp/src/api/group.ts+4−0
@@ -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
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -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 }
packages/plugins/mcp/src/sdk/connection.ts+19−3
@@ -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 a336 // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable
322 // legacy-only transport, and stdio servers are spawned per call where the337 // 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" },
packages/plugins/mcp/src/sdk/plugin.ts+9−0
@@ -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 > 0380 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: hasEnv1057 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" }],
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.
6import { McpServer } from "@modelcontextprotocol/server";
7import { serveStdio } from "@modelcontextprotocol/server/stdio";
8import * as z from "zod/v4";
9
10serveStdio(
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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate } from "effect";
3import { fileURLToPath } from "node:url";
4
5import { createMcpConnector, type StdioConnectorInput } from "./connection";
6
7const fixture = fileURLToPath(new URL("./stdio-negotiation-test-server.ts", import.meta.url));
8
9const 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
19const withConnection = (input: StdioConnectorInput) =>
20 Effect.acquireRelease(createMcpConnector(input).pipe(Effect.orDie), (connection) =>
21 Effect.promise(connection.close),
22 );
23
24describe("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
31export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]);31export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]);
32export type McpTransport = typeof McpTransport.Type;32export 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. */
45export const McpStdioVersionNegotiation = Schema.Literals(["legacy", "auto"]);
46export 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 env228 /** 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 as229 * 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 no230 * for remote servers. Optional so pre-revamp stdio configs (which had no