Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/stdio-modern-negotiation.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions e2e/local/stdio-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
);
}),
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const toServerInput = (
envVars?: readonly string[];
env?: Record<string, string>;
cwd?: string;
versionNegotiation?: "legacy" | "auto";
slug?: string;
};
return {
Expand All @@ -50,6 +51,7 @@ const toServerInput = (
envVars: p.envVars ? [...p.envVars] : undefined,
env: p.env,
cwd: p.cwd,
versionNegotiation: p.versionNegotiation,
slug: p.slug,
};
}
Expand Down
22 changes: 19 additions & 3 deletions packages/plugins/mcp/src/sdk/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}),
Expand Down Expand Up @@ -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,
Expand All @@ -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" },
Expand Down
9 changes: 9 additions & 0 deletions packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
expandMcpAuthMethodInputs,
mcpAuthMethodFromShorthand,
normalizeMcpAuthMethods,
McpStdioVersionNegotiation,
parseMcpIntegrationConfig,
type McpIntegrationConfig as McpIntegrationConfigType,
type McpStdioEnvMethod,
Expand Down Expand Up @@ -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),
});

Expand Down Expand Up @@ -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 }]
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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" }],
Expand Down
24 changes: 24 additions & 0 deletions packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Stdio MCP fixture for stdio-negotiation.test.ts, spawned as a child process
// with `bun run <this file>`. 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" },
);
64 changes: 64 additions & 0 deletions packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<StdioConnectorInput, "transport" | "command">> & {
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");
}),
),
);
});
17 changes: 17 additions & 0 deletions packages/plugins/mcp/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading