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
5 changes: 5 additions & 0 deletions .changeset/reland-outbound-mcp-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-mcp": minor
---

Restore MCP spec 2026-07-28 negotiation for outbound MCP connections. Remote Streamable HTTP connections auto-negotiate the modern protocol era again, stdio servers can opt in per integration, and the negotiated era is recorded on connection handshake traces.
19 changes: 18 additions & 1 deletion 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
2 changes: 1 addition & 1 deletion packages/hosts/mcp-apps-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"@executor-js/react": "workspace:*",
"@executor-js/runtime-quickjs": "workspace:*",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/sdk": "^1.12.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tanstack/react-query": "^5.99.0",
"effect": "catalog:",
"esbuild": "^0.27.7",
Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"@effect/platform-node": "catalog:",
"@executor-js/config": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "4.3.6"
},
Expand All @@ -73,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
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/sdk/connection-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect";
import type { McpConnection, McpConnector } from "./connection";
import type { McpInvocationError } from "./errors";

// The pool preserves sessions for sessionful legacy servers. Stateless
// 2026-07-28 servers do not need it, but retaining a cheap idle client is
// harmless and keeps one lifecycle for both protocol eras.

const IDLE_TTL_MS = 5 * 60 * 1_000;

type IdleConnection = {
Expand Down
46 changes: 35 additions & 11 deletions packages/plugins/mcp/src/sdk/connection.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import {
Client,
SSEClientTransport,
StreamableHTTPClientTransport,
type FetchLike,
type OAuthClientProvider,
} from "@modelcontextprotocol/client";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
import { Effect, Layer, Predicate, Stream } from "effect";
import { HttpClient, HttpClientRequest } from "effect/unstable/http";

// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module
// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process`
// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at
// SIGSEGV on module instantiation. Cloud callers set
// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and
// `cross-spawn` eagerly at evaluation time, which crashes workerd (including
// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set
// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;
// prod bundles that DO use stdio load it via a dynamic import inside the
// stdio branch of `createMcpConnector`.
Expand Down Expand Up @@ -201,12 +203,13 @@ const fetchFromHttpClientLayer = (
// MCP plugin runs inside a Cloudflare Worker (executor.sh). The
// cfworker validator does not use code generation and works in every
// runtime we ship to.
const createClient = (): Client =>
const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>
new Client(
{ name: "executor-mcp", version: "0.1.0" },
{
capabilities: { elicitation: { form: {}, url: {} } },
jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
...(versionNegotiation === undefined ? {} : { versionNegotiation }),
},
);

Expand Down Expand Up @@ -247,9 +250,10 @@ const connectionFailure = (
const connectClient = (input: {
transport: string;
createTransport: () => Parameters<Client["connect"]>[0];
versionNegotiation?: { readonly mode: "auto" };
}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
Effect.gen(function* () {
const client = createClient();
const client = createClient(input.versionNegotiation);
const transportInstance = input.createTransport();

yield* Effect.tryPromise({
Expand All @@ -262,6 +266,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 @@ -300,6 +313,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 @@ -319,8 +338,13 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => {

const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});

// 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" },
createTransport: () =>
new StreamableHTTPClientTransport(endpoint, {
requestInit,
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/mcp/src/sdk/elicitation.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Predicate, Schema, Semaphore } from "effect";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";
import type { JsonSchemaType } from "@modelcontextprotocol/client";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";

import {
AuthTemplateSlug,
Expand Down Expand Up @@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => {
]),
);
expect(schema?.outputTypeScript).toContain('type: "text"');
expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }");
expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;");

const result = yield* executor.execute(
simpleEcho.address,
Expand Down
20 changes: 17 additions & 3 deletions packages/plugins/mcp/src/sdk/http-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";

// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
import { insufficientScopeFromCause } from "./http-status";
Expand All @@ -9,7 +10,8 @@ import { insufficientScopeFromCause } from "./http-status";
// - with an authProvider (the production OAuth path): the StreamableHTTP
// transport consumes the insufficient_scope challenge itself, retries
// with the broader scope, and only when THAT fails throws the fixed
// "Server returned 403 after trying upscoping" message.
// typed `InsufficientScopeError`, or after retry exhaustion the fixed
// `SdkHttpError` step-up message.
describe("insufficientScopeFromCause", () => {
it("detects the OAuth error body embedded in a transport message", () => {
expect(
Expand All @@ -31,9 +33,21 @@ describe("insufficientScopeFromCause", () => {
).toBe(true);
});

it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {
it("detects the SDK's typed insufficient-scope failure", () => {
expect(
insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")),
insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })),
).toBe(true);
});

it("detects the SDK's exhausted step-up failure (the authProvider path)", () => {
expect(
insufficientScopeFromCause(
new SdkHttpError(
SdkErrorCode.ClientHttpForbidden,
"Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)",
{ status: 403 },
),
),
).toBe(true);
});

Expand Down
Loading
Loading