Skip to content

Commit d89c733

Browse files
authored
Revert "Migrate outbound MCP client to SDK v2" (8cb0d22) (#1692)
* Revert "Migrate outbound MCP client to SDK v2" (#8cb0d22a3) * Revert "Opt-in modern protocol negotiation for stdio MCP servers" (#1646) Depends on the v2 client getProtocolEra API removed above.
1 parent 98a34e3 commit d89c733

23 files changed

Lines changed: 599 additions & 469 deletions

‎bun.lock‎

Lines changed: 1 addition & 4 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: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -146,36 +146,6 @@ 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");
179149
}),
180150
);
181151
}),

‎packages/hosts/mcp-apps-shell/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"@executor-js/react": "workspace:*",
7070
"@executor-js/runtime-quickjs": "workspace:*",
7171
"@modelcontextprotocol/ext-apps": "^1.7.4",
72-
"@modelcontextprotocol/sdk": "^1.29.0",
72+
"@modelcontextprotocol/sdk": "^1.12.1",
7373
"@tanstack/react-query": "^5.99.0",
7474
"effect": "catalog:",
7575
"esbuild": "^0.27.7",

‎packages/plugins/mcp/package.json‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,6 @@
6565
"@effect/platform-node": "catalog:",
6666
"@executor-js/config": "workspace:*",
6767
"@executor-js/sdk": "workspace:*",
68-
"@modelcontextprotocol/client": "2.0.0",
69-
"@modelcontextprotocol/core": "2.0.0",
7068
"@modelcontextprotocol/sdk": "^1.29.0",
7169
"zod": "4.3.6"
7270
},
@@ -75,7 +73,6 @@
7573
"@effect/vitest": "catalog:",
7674
"@executor-js/api": "workspace:*",
7775
"@executor-js/react": "workspace:*",
78-
"@modelcontextprotocol/server": "2.0.0",
7976
"@types/node": "catalog:",
8077
"@types/react": "catalog:",
8178
"bun-types": "catalog:",

‎packages/plugins/mcp/src/api/group.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,6 @@ 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"])),
6460
slug: Schema.optional(Schema.String),
6561
});
6662

‎packages/plugins/mcp/src/api/handlers.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ const toServerInput = (
3939
envVars?: readonly string[];
4040
env?: Record<string, string>;
4141
cwd?: string;
42-
versionNegotiation?: "legacy" | "auto";
4342
slug?: string;
4443
};
4544
return {
@@ -51,7 +50,6 @@ const toServerInput = (
5150
envVars: p.envVars ? [...p.envVars] : undefined,
5251
env: p.env,
5352
cwd: p.cwd,
54-
versionNegotiation: p.versionNegotiation,
5553
slug: p.slug,
5654
};
5755
}

‎packages/plugins/mcp/src/sdk/connection-pool.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@ import { Cause, Effect, Exit, Predicate } from "effect";
33
import type { McpConnection, McpConnector } from "./connection";
44
import type { McpInvocationError } from "./errors";
55

6-
// The pool preserves sessions for sessionful legacy servers. Stateless
7-
// 2026-07-28 servers do not need it, but retaining a cheap idle client is
8-
// harmless and keeps one lifecycle for both protocol eras.
9-
106
const IDLE_TTL_MS = 5 * 60 * 1_000;
117

128
type IdleConnection = {

‎packages/plugins/mcp/src/sdk/connection.ts‎

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1-
import {
2-
Client,
3-
SSEClientTransport,
4-
StreamableHTTPClientTransport,
5-
type FetchLike,
6-
type OAuthClientProvider,
7-
} from "@modelcontextprotocol/client";
8-
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
1+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5+
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
6+
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
97
import { Effect, Layer, Predicate, Stream } from "effect";
108
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
119

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

@@ -250,10 +247,9 @@ const connectionFailure = (
250247
const connectClient = (input: {
251248
transport: string;
252249
createTransport: () => Parameters<Client["connect"]>[0];
253-
versionNegotiation?: { readonly mode: "auto" };
254250
}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
255251
Effect.gen(function* () {
256-
const client = createClient(input.versionNegotiation);
252+
const client = createClient();
257253
const transportInstance = input.createTransport();
258254

259255
yield* Effect.tryPromise({
@@ -266,15 +262,6 @@ const connectClient = (input: {
266262
catch: (cause) =>
267263
connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause),
268264
}).pipe(
269-
// The negotiated era ("modern" = 2026-07-28 server/discover, "legacy" =
270-
// 2025 initialize) is otherwise invisible: both eras list and call tools
271-
// identically, so traces are the one place an integration author can
272-
// verify which handshake a connection actually used.
273-
Effect.tap(() =>
274-
Effect.annotateCurrentSpan({
275-
"plugin.mcp.protocol_era": client.getProtocolEra() ?? "unknown",
276-
}),
277-
),
278265
Effect.withSpan("plugin.mcp.connection.handshake", {
279266
attributes: { "plugin.mcp.transport": input.transport },
280267
}),
@@ -313,12 +300,6 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => {
313300

314301
return yield* connectClient({
315302
transport: "stdio",
316-
// Opt-in per integration (default legacy) — see
317-
// `McpStdioVersionNegotiation` for why stdio does not follow the
318-
// remote transport's unconditional auto.
319-
...(input.versionNegotiation === "auto"
320-
? { versionNegotiation: { mode: "auto" as const } }
321-
: {}),
322303
createTransport: () =>
323304
createStdioTransport({
324305
command,
@@ -338,13 +319,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => {
338319

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

341-
// Auto-negotiate the 2026-07-28 era unconditionally only on Streamable
342-
// HTTP. SSE is a legacy-only transport; stdio negotiates per the
343-
// integration's `versionNegotiation` (default legacy — see the stdio
344-
// branch above).
345322
const connectStreamableHttp = connectClient({
346323
transport: "streamable-http",
347-
versionNegotiation: { mode: "auto" },
348324
createTransport: () =>
349325
new StreamableHTTPClientTransport(endpoint, {
350326
requestInit,

‎packages/plugins/mcp/src/sdk/elicitation.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "@effect/vitest";
22
import { Effect, Predicate, Schema, Semaphore } from "effect";
3-
import type { JsonSchemaType } from "@modelcontextprotocol/client";
4-
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
3+
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
4+
import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";
55

66
import {
77
AuthTemplateSlug,
@@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => {
226226
]),
227227
);
228228
expect(schema?.outputTypeScript).toContain('type: "text"');
229-
expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;");
229+
expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }");
230230

231231
const result = yield* executor.execute(
232232
simpleEcho.address,

‎packages/plugins/mcp/src/sdk/http-status.test.ts‎

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { describe, expect, it } from "@effect/vitest";
2-
import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
32

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

36-
it("detects the SDK's typed insufficient-scope failure", () => {
34+
it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {
3735
expect(
38-
insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })),
39-
).toBe(true);
40-
});
41-
42-
it("detects the SDK's exhausted step-up failure (the authProvider path)", () => {
43-
expect(
44-
insufficientScopeFromCause(
45-
new SdkHttpError(
46-
SdkErrorCode.ClientHttpForbidden,
47-
"Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)",
48-
{ status: 403 },
49-
),
50-
),
36+
insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")),
5137
).toBe(true);
5238
});
5339

0 commit comments

Comments
 (0)