History

Revert "Migrate outbound MCP client to SDK v2" (8cb0d22a3) (#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.

Rhys Sullivan committed 2 months ago

Commit d89c7331 parent 98a34e323 files · +599 −469Browse files

Unified · Split

23 files
  1. bun.lock +1 −4
  2. e2e/local/stdio-mcp.test.ts +0 −30
  3. packages/hosts/mcp-apps-shell/package.json +1 −1
  4. packages/plugins/mcp/package.json +0 −3
  5. packages/plugins/mcp/src/api/group.ts +0 −4
  6. packages/plugins/mcp/src/api/handlers.ts +0 −2
  7. packages/plugins/mcp/src/sdk/connection-pool.ts +0 −4
  8. packages/plugins/mcp/src/sdk/connection.ts +11 −35
  9. packages/plugins/mcp/src/sdk/elicitation.test.ts +3 −3
  10. packages/plugins/mcp/src/sdk/http-status.test.ts +3 −17
  11. packages/plugins/mcp/src/sdk/http-status.ts +20 −25
  12. packages/plugins/mcp/src/sdk/invoke.test.ts +5 −10
  13. packages/plugins/mcp/src/sdk/invoke.ts +15 −24
  14. packages/plugins/mcp/src/sdk/plugin.ts +5 −15
  15. packages/plugins/mcp/src/sdk/probe-shape.test.ts +2 −90
  16. packages/plugins/mcp/src/sdk/probe-shape.ts +10 −51
  17. packages/plugins/mcp/src/sdk/stdio-connector.ts +3 −4
  18. packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts +0 −24
  19. packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts +0 −64
  20. packages/plugins/mcp/src/sdk/testing-fixtures.test.ts +3 −5
  21. packages/plugins/mcp/src/sdk/types.ts +0 −17
  22. packages/plugins/mcp/src/testing/server.ts +2 −37
  23. plans/kill-plugin-system.md +515 −0
bun.lock+1−4
@@ -728,7 +728,7 @@ · 727 unmodified lines
728 "@executor-js/react": "workspace:*",728 "@executor-js/react": "workspace:*",
729 "@executor-js/runtime-quickjs": "workspace:*",729 "@executor-js/runtime-quickjs": "workspace:*",
730 "@modelcontextprotocol/ext-apps": "^1.7.4",730 "@modelcontextprotocol/ext-apps": "^1.7.4",
731 "@modelcontextprotocol/sdk": "^1.29.0",731 "@modelcontextprotocol/sdk": "^1.12.1",
732 "@tanstack/react-query": "^5.99.0",732 "@tanstack/react-query": "^5.99.0",
733 "effect": "catalog:",733 "effect": "catalog:",
734 "esbuild": "^0.27.7",734 "esbuild": "^0.27.7",
@@ -1001,8 +1001,6 @@ · 266 unmodified lines
1001 "@effect/platform-node": "catalog:",1001 "@effect/platform-node": "catalog:",
1002 "@executor-js/config": "workspace:*",1002 "@executor-js/config": "workspace:*",
1003 "@executor-js/sdk": "workspace:*",1003 "@executor-js/sdk": "workspace:*",
1004 "@modelcontextprotocol/client": "2.0.0",
1005 "@modelcontextprotocol/core": "2.0.0",
1006 "@modelcontextprotocol/sdk": "^1.29.0",1004 "@modelcontextprotocol/sdk": "^1.29.0",
1007 "zod": "4.3.6",1005 "zod": "4.3.6",
1008 },1006 },
@@ -1011,7 +1009,6 @@ · 2 unmodified lines
1011 "@effect/vitest": "catalog:",1009 "@effect/vitest": "catalog:",
1012 "@executor-js/api": "workspace:*",1010 "@executor-js/api": "workspace:*",
1013 "@executor-js/react": "workspace:*",1011 "@executor-js/react": "workspace:*",
1014 "@modelcontextprotocol/server": "2.0.0",
1015 "@types/node": "catalog:",1012 "@types/node": "catalog:",
1016 "@types/react": "catalog:",1013 "@types/react": "catalog:",
1017 "bun-types": "catalog:",1014 "bun-types": "catalog:",
e2e/local/stdio-mcp.test.ts+0−30
@@ -146,36 +146,6 @@ 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");
179 }),149 }),
180 );150 );
181 }),151 }),
packages/hosts/mcp-apps-shell/package.json+1−1
@@ -69,7 +69,7 @@ · 68 unmodified lines
69 "@executor-js/react": "workspace:*",69 "@executor-js/react": "workspace:*",
70 "@executor-js/runtime-quickjs": "workspace:*",70 "@executor-js/runtime-quickjs": "workspace:*",
71 "@modelcontextprotocol/ext-apps": "^1.7.4",71 "@modelcontextprotocol/ext-apps": "^1.7.4",
72 "@modelcontextprotocol/sdk": "^1.29.0",72 "@modelcontextprotocol/sdk": "^1.12.1",
73 "@tanstack/react-query": "^5.99.0",73 "@tanstack/react-query": "^5.99.0",
74 "effect": "catalog:",74 "effect": "catalog:",
75 "esbuild": "^0.27.7",75 "esbuild": "^0.27.7",
packages/plugins/mcp/package.json+0−3
@@ -65,8 +65,6 @@ · 64 unmodified lines
65 "@effect/platform-node": "catalog:",65 "@effect/platform-node": "catalog:",
66 "@executor-js/config": "workspace:*",66 "@executor-js/config": "workspace:*",
67 "@executor-js/sdk": "workspace:*",67 "@executor-js/sdk": "workspace:*",
68 "@modelcontextprotocol/client": "2.0.0",
69 "@modelcontextprotocol/core": "2.0.0",
70 "@modelcontextprotocol/sdk": "^1.29.0",68 "@modelcontextprotocol/sdk": "^1.29.0",
71 "zod": "4.3.6"69 "zod": "4.3.6"
72 },70 },
@@ -75,7 +73,6 @@ · 2 unmodified lines
75 "@effect/vitest": "catalog:",73 "@effect/vitest": "catalog:",
76 "@executor-js/api": "workspace:*",74 "@executor-js/api": "workspace:*",
77 "@executor-js/react": "workspace:*",75 "@executor-js/react": "workspace:*",
78 "@modelcontextprotocol/server": "2.0.0",
79 "@types/node": "catalog:",76 "@types/node": "catalog:",
80 "@types/react": "catalog:",77 "@types/react": "catalog:",
81 "bun-types": "catalog:",78 "bun-types": "catalog:",
packages/plugins/mcp/src/api/group.ts+0−4
@@ -57,10 +57,6 @@ 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"])),
64 slug: Schema.optional(Schema.String),60 slug: Schema.optional(Schema.String),
65});61});
66 62
packages/plugins/mcp/src/api/handlers.ts+0−2
@@ -39,7 +39,6 @@ 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";
43 slug?: string;42 slug?: string;
44 };43 };
45 return {44 return {
@@ -51,7 +50,6 @@ const toServerInput = ( · 5 unmodified lines
51 envVars: p.envVars ? [...p.envVars] : undefined,50 envVars: p.envVars ? [...p.envVars] : undefined,
52 env: p.env,51 env: p.env,
53 cwd: p.cwd,52 cwd: p.cwd,
54 versionNegotiation: p.versionNegotiation,
55 slug: p.slug,53 slug: p.slug,
56 };54 };
57 }55 }
packages/plugins/mcp/src/sdk/connection-pool.ts+0−4
@@ -3,10 +3,6 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines
3import type { McpConnection, McpConnector } from "./connection";3import type { McpConnection, McpConnector } from "./connection";
4import type { McpInvocationError } from "./errors";4import type { McpInvocationError } from "./errors";
5 5
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
10const IDLE_TTL_MS = 5 * 60 * 1_000;6const IDLE_TTL_MS = 5 * 60 * 1_000;
11 7
12type IdleConnection = {8type IdleConnection = {
packages/plugins/mcp/src/sdk/connection.ts+11−35
@@ -1,18 +1,16 @@
1import {1import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2 Client,2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3 SSEClientTransport,3import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4 StreamableHTTPClientTransport,4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5 type FetchLike,5import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
6 type OAuthClientProvider,6import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
7} from "@modelcontextprotocol/client";
8import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
9import { Effect, Layer, Predicate, Stream } from "effect";7import { Effect, Layer, Predicate, Stream } from "effect";
10import { HttpClient, HttpClientRequest } from "effect/unstable/http";8import { HttpClient, HttpClientRequest } from "effect/unstable/http";
11 9
12// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module10// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module
13// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and11// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process`
14// `cross-spawn` eagerly at evaluation time, which crashes workerd (including12// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at
15// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set13// SIGSEGV on module instantiation. Cloud callers set
16// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;14// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;
17// prod bundles that DO use stdio load it via a dynamic import inside the15// prod bundles that DO use stdio load it via a dynamic import inside the
18// stdio branch of `createMcpConnector`.16// stdio branch of `createMcpConnector`.
@@ -203,13 +201,12 @@ const fetchFromHttpClientLayer = ( · 184 unmodified lines
203// MCP plugin runs inside a Cloudflare Worker (executor.sh). The201// MCP plugin runs inside a Cloudflare Worker (executor.sh). The
204// cfworker validator does not use code generation and works in every202// cfworker validator does not use code generation and works in every
205// runtime we ship to.203// runtime we ship to.
206const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>204const createClient = (): Client =>
207 new Client(205 new Client(
208 { name: "executor-mcp", version: "0.1.0" },206 { name: "executor-mcp", version: "0.1.0" },
209 {207 {
210 capabilities: { elicitation: { form: {}, url: {} } },208 capabilities: { elicitation: { form: {}, url: {} } },
211 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),209 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
212 ...(versionNegotiation === undefined ? {} : { versionNegotiation }),
213 },210 },
214 );211 );
215 212
@@ -250,10 +247,9 @@ const connectionFailure = ( · 34 unmodified lines
250const connectClient = (input: {247const connectClient = (input: {
251 transport: string;248 transport: string;
252 createTransport: () => Parameters<Client["connect"]>[0];249 createTransport: () => Parameters<Client["connect"]>[0];
253 versionNegotiation?: { readonly mode: "auto" };
254}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>250}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
255 Effect.gen(function* () {251 Effect.gen(function* () {
256 const client = createClient(input.versionNegotiation);252 const client = createClient();
257 const transportInstance = input.createTransport();253 const transportInstance = input.createTransport();
258 254
259 yield* Effect.tryPromise({255 yield* Effect.tryPromise({
@@ -266,15 +262,6 @@ const connectClient = (input: { · 6 unmodified lines
266 catch: (cause) =>262 catch: (cause) =>
267 connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause),263 connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause),
268 }).pipe(264 }).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 ),
278 Effect.withSpan("plugin.mcp.connection.handshake", {265 Effect.withSpan("plugin.mcp.connection.handshake", {
279 attributes: { "plugin.mcp.transport": input.transport },266 attributes: { "plugin.mcp.transport": input.transport },
280 }),267 }),
@@ -313,12 +300,6 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 32 unmodified lines
313 300
314 return yield* connectClient({301 return yield* connectClient({
315 transport: "stdio",302 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 : {}),
322 createTransport: () =>303 createTransport: () =>
323 createStdioTransport({304 createStdioTransport({
324 command,305 command,
@@ -338,13 +319,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 13 unmodified lines
338 319
339 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});320 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
340 321
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).
345 const connectStreamableHttp = connectClient({322 const connectStreamableHttp = connectClient({
346 transport: "streamable-http",323 transport: "streamable-http",
347 versionNegotiation: { mode: "auto" },
348 createTransport: () =>324 createTransport: () =>
349 new StreamableHTTPClientTransport(endpoint, {325 new StreamableHTTPClientTransport(endpoint, {
350 requestInit,326 requestInit,
packages/plugins/mcp/src/sdk/elicitation.test.ts+3−3
@@ -1,7 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate, Schema, Semaphore } from "effect";2import { Effect, Predicate, Schema, Semaphore } from "effect";
3import type { JsonSchemaType } from "@modelcontextprotocol/client";3import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
4import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";4import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";
5 5
6import {6import {
7 AuthTemplateSlug,7 AuthTemplateSlug,
@@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { · 218 unmodified lines
226 ]),226 ]),
227 );227 );
228 expect(schema?.outputTypeScript).toContain('type: "text"');228 expect(schema?.outputTypeScript).toContain('type: "text"');
229 expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;");229 expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }");
230 230
231 const result = yield* executor.execute(231 const result = yield* executor.execute(
232 simpleEcho.address,232 simpleEcho.address,
packages/plugins/mcp/src/sdk/http-status.test.ts+3−17
@@ -1,5 +1,4 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
3 2
4// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors3// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
5import { insufficientScopeFromCause } from "./http-status";4import { insufficientScopeFromCause } from "./http-status";
@@ -10,8 +9,7 @@ import { insufficientScopeFromCause } from "./http-status"; · 4 unmodified lines
10// - with an authProvider (the production OAuth path): the StreamableHTTP9// - with an authProvider (the production OAuth path): the StreamableHTTP
11// transport consumes the insufficient_scope challenge itself, retries10// transport consumes the insufficient_scope challenge itself, retries
12// with the broader scope, and only when THAT fails throws the fixed11// with the broader scope, and only when THAT fails throws the fixed
13// typed `InsufficientScopeError`, or after retry exhaustion the fixed12// "Server returned 403 after trying upscoping" message.
14// `SdkHttpError` step-up message.
15describe("insufficientScopeFromCause", () => {13describe("insufficientScopeFromCause", () => {
16 it("detects the OAuth error body embedded in a transport message", () => {14 it("detects the OAuth error body embedded in a transport message", () => {
17 expect(15 expect(
@@ -33,21 +31,9 @@ describe("insufficientScopeFromCause", () => { · 15 unmodified lines
33 ).toBe(true);31 ).toBe(true);
34 });32 });
35 33
36 it("detects the SDK's typed insufficient-scope failure", () => {34 it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {
37 expect(35 expect(
38 insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })),36 insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")),
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 ),
51 ).toBe(true);37 ).toBe(true);
52 });38 });
53 39
packages/plugins/mcp/src/sdk/http-status.ts+20−25
@@ -1,8 +1,7 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Extract the HTTP status from an MCP SDK transport error. The SDK surfaces2// Extract the HTTP status from an MCP SDK transport error. The SDK surfaces
3// transport failures two ways: an `SdkHttpError` carrying a numeric `status`,3// transport failures two ways: a `StreamableHTTPError` subclass carrying a
4// and an `SseError` carrying a numeric `code`. The SSE transport also retains4// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`.
5// its historic POST-failure message for errors created below EventSource.
6// Shared by the invoke path (classifies tool-call failures) and the connect5// Shared by the invoke path (classifies tool-call failures) and the connect
7// path (so a 401/403 during the handshake reaches the liveness health check).6// path (so a 401/403 during the handshake reaches the liveness health check).
8// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
@@ -10,13 +9,13 @@ · 1 unmodified line
10import { Option, Schema } from "effect";9import { Option, Schema } from "effect";
11 10
12import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";11import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
13import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client";12import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14 13
15const SsePostErrorCause = Schema.Struct({ message: Schema.String });14const SsePostErrorCause = Schema.Struct({ message: Schema.String });
16const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);15const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
17 16
18// V2 still constructs this exact message in SSEClientTransport._send. A format17// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify
19// drift just yields undefined (generic error, no crash).18// on SDK bumps. A format drift just yields undefined (generic error, no crash).
20const statusFromSsePostError = (cause: unknown): number | undefined =>19const statusFromSsePostError = (cause: unknown): number | undefined =>
21 Option.match(decodeSsePostErrorCause(cause), {20 Option.match(decodeSsePostErrorCause(cause), {
22 onNone: () => undefined,21 onNone: () => undefined,
@@ -27,36 +26,32 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 4 unmodified lines
27 },26 },
28 });27 });
29 28
30const statusFromTypedTransportError = (cause: unknown): number | undefined => {29const statusFromStreamableHttpError = (cause: unknown): number | undefined => {
31 if (SdkHttpError.isInstance(cause)) return cause.status;30 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK exposes transport HTTP failures as this Error subclass; protocol errors can carry the same numeric code
32 if (SseError.isInstance(cause)) {31 if (!(cause instanceof StreamableHTTPError)) return undefined;
33 const code = cause.code;32 const code = cause.code;
34 return code !== undefined && code >= 100 && code <= 599 ? code : undefined;33 return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
35 }
36 return undefined;
37};34};
38 35
39export const httpStatusFromCause = (cause: unknown): number | undefined =>36export const httpStatusFromCause = (cause: unknown): number | undefined =>
40 statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause);37 statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
41 38
42// The SDK embeds the upstream response text in the transport error message39// The SDK embeds the upstream response text in the transport error message
43// ("Error POSTing to endpoint: <body>"), which is the only place a 403's body40// ("Error POSTing to endpoint: <body>"), which is the only place a 403's body
44// survives for connections without an authProvider. For OAuth connections the41// survives for connections without an authProvider. For OAuth connections the
45// StreamableHTTP transport consumes the insufficient_scope challenge itself.42// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF:
46// V2 throws `InsufficientScopeError` when configured not to reauthorize; after43// it re-runs auth requesting the broader scope, and only when that upscoped
47// exhausting its step-up retries it throws `SdkHttpError` with the exact fixed44// retry still 403s does it throw — with the fixed message matched below
48// message matched below (verified against the installed v2 transport source).45// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on
49// Both paths mean the same thing: the grant does not cover the operation, and46// SDK bumps). Both paths mean the same thing: the grant does not cover the
50// re-running the identical flow cannot help. Strict matching47// operation, and re-running the identical flow cannot help. Strict matching
51// (exact serialized field forms via the shared core detector, or the SDK's48// (exact serialized field forms via the shared core detector, or the SDK's
52// exact step-up message) — a miss stays on the generic auth path.49// exact upscoping message) — a miss stays on the generic auth path.
53const SDK_STEP_UP_EXHAUSTED_RE =50const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/;
54 /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;
55 51
56export const insufficientScopeFromCause = (cause: unknown): boolean =>52export const insufficientScopeFromCause = (cause: unknown): boolean =>
57 InsufficientScopeError.isInstance(cause) ||
58 Option.match(decodeSsePostErrorCause(cause), {53 Option.match(decodeSsePostErrorCause(cause), {
59 onNone: () => false,54 onNone: () => false,
60 onSome: ({ message }) =>55 onSome: ({ message }) =>
61 insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message),56 insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message),
62 });57 });
packages/plugins/mcp/src/sdk/invoke.test.ts+5−10
@@ -2,12 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
2import { Effect, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3import { HttpServerResponse } from "effect/unstable/http";3import { HttpServerResponse } from "effect/unstable/http";
4 4
5import {5import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
6 ProtocolError,6import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7 SdkErrorCode,7import { McpError } from "@modelcontextprotocol/sdk/types.js";
8 SdkHttpError,
9 type OAuthClientProvider,
10} from "@modelcontextprotocol/client";
11import { ElicitationResponse } from "@executor-js/sdk";8import { ElicitationResponse } from "@executor-js/sdk";
12import { serveTestHttpApp } from "@executor-js/sdk/testing";9import { serveTestHttpApp } from "@executor-js/sdk/testing";
13 10
@@ -111,16 +108,14 @@ const invocationRejectionCases = [ · 97 unmodified lines
111 name: "wraps callTool rejection with a stable message and status",108 name: "wraps callTool rejection with a stable message and status",
112 toolId: "blocked",109 toolId: "blocked",
113 transport: "streamable-http",110 transport: "streamable-http",
114 cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", {111 cause: new StreamableHTTPError(401, "token=do-not-leak"),
115 status: 401,
116 }),
117 expectedStatus: 401 as number | undefined,112 expectedStatus: 401 as number | undefined,
118 },113 },
119 {114 {
120 name: "does not treat MCP protocol error codes as HTTP statuses",115 name: "does not treat MCP protocol error codes as HTTP statuses",
121 toolId: "protocol_error",116 toolId: "protocol_error",
122 transport: "streamable-http",117 transport: "streamable-http",
123 cause: new ProtocolError(401, "application-level do-not-leak"),118 cause: new McpError(401, "application-level do-not-leak"),
124 expectedStatus: undefined,119 expectedStatus: undefined,
125 },120 },
126 {121 {
packages/plugins/mcp/src/sdk/invoke.ts+15−24
@@ -8,7 +8,7 @@ · 7 unmodified lines
8// legitimately retain state in that session. The pool keeps one idle8// legitimately retain state in that session. The pool keeps one idle
9// connection per resolved identity and leases it exclusively per invoke;9// connection per resolved identity and leases it exclusively per invoke;
10// stdio and callers without a pool remain strictly per-call.10// stdio and callers without a pool remain strictly per-call.
11// 2. Installing a per-invocation `elicitation/create` handler that bridges11// 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges
12// MCP's elicit capability into the host's elicit function threaded via12// MCP's elicit capability into the host's elicit function threaded via
13// `InvokeToolInput.elicit`.13// `InvokeToolInput.elicit`.
14// 3. Calling `client.callTool({ name, arguments })`.14// 3. Calling `client.callTool({ name, arguments })`.
@@ -16,7 +16,12 @@ · 1 unmodified line
16 16
17import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";17import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
18 18
19import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";19import {
20 ElicitRequestSchema,
21 ErrorCode,
22 McpError,
23 ToolListChangedNotificationSchema,
24} from "@modelcontextprotocol/sdk/types.js";
20 25
21import {26import {
22 ElicitationId,27 ElicitationId,
@@ -61,10 +66,9 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines
61 66
62const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>67const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>
63 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass68 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass
64 cause instanceof ProtocolError &&69 cause instanceof McpError &&
65 (cause.code === ProtocolErrorCode.InvalidParams ||70 (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.MethodNotFound) &&
66 cause.code === ProtocolErrorCode.MethodNotFound) &&71 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's McpError, whose message carries the only unknown-tool discriminator the protocol provides
67 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides
68 isUnknownToolMessage(cause.message, toolName);72 isUnknownToolMessage(cause.message, toolName);
69 73
70// ---------------------------------------------------------------------------74// ---------------------------------------------------------------------------
@@ -89,17 +93,6 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines
89type McpElicitParams = typeof McpElicitParams.Type;93type McpElicitParams = typeof McpElicitParams.Type;
90 94
91const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams);95const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams);
92const decodeElicitContent = Schema.decodeUnknownSync(
93 Schema.Record(
94 Schema.String,
95 Schema.Union([
96 Schema.String,
97 Schema.Number,
98 Schema.Boolean,
99 Schema.mutable(Schema.Array(Schema.String)),
100 ]),
101 ),
102);
103 96
104const toElicitationRequest = (params: McpElicitParams): ElicitationRequest =>97const toElicitationRequest = (params: McpElicitParams): ElicitationRequest =>
105 params.mode === "url"98 params.mode === "url"
@@ -114,7 +107,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 8 unmodified lines
114 });107 });
115 108
116const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => {109const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => {
117 client.setRequestHandler("elicitation/create", async (request: { params: unknown }) => {110 client.setRequestHandler(ElicitRequestSchema, async (request: { params: unknown }) => {
118 const params = decodeElicitParams(request.params);111 const params = decodeElicitParams(request.params);
119 const req = toElicitationRequest(params);112 const req = toElicitationRequest(params);
120 // Use runPromiseExit so we can inspect typed failures — `elicit`113 // Use runPromiseExit so we can inspect typed failures — `elicit`
@@ -126,9 +119,7 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic · 5 unmodified lines
126 const response = exit.value;119 const response = exit.value;
127 return {120 return {
128 action: response.action,121 action: response.action,
129 ...(response.action === "accept" && response.content122 ...(response.action === "accept" && response.content ? { content: response.content } : {}),
130 ? { content: decodeElicitContent(response.content) }
131 : {}),
132 };123 };
133 }124 }
134 const failure = exit.cause.reasons.find(Cause.isFailReason);125 const failure = exit.cause.reasons.find(Cause.isFailReason);
@@ -158,7 +149,7 @@ const installToolListChangedHandler = ( · 23 unmodified lines
158 onToolListChanged: (() => void) | undefined,149 onToolListChanged: (() => void) | undefined,
159): void => {150): void => {
160 if (!onToolListChanged) return;151 if (!onToolListChanged) return;
161 client.setNotificationHandler("notifications/tools/list_changed", () => {152 client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
162 onToolListChanged();153 onToolListChanged();
163 });154 });
164};155};
@@ -198,8 +189,8 @@ const useConnection = ( · 33 unmodified lines
198 });189 });
199 }190 }
200 const status = httpStatusFromCause(cause);191 const status = httpStatusFromCause(cause);
201 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes192 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its McpError subclass; transport failures use other error shapes
202 const protocolFailure = cause instanceof ProtocolError;193 const protocolFailure = cause instanceof McpError;
203 return new McpInvocationError({194 return new McpInvocationError({
204 toolName,195 toolName,
205 message: `MCP tool call failed for ${toolName}`,196 message: `MCP tool call failed for ${toolName}`,
packages/plugins/mcp/src/sdk/plugin.ts+5−15
@@ -1,8 +1,9 @@
1import { Effect, Layer, Option, Result, Schema } from "effect";1import { Effect, Layer, Option, Result, Schema } from "effect";
2import type { HttpClient } from "effect/unstable/http";2import type { HttpClient } from "effect/unstable/http";
3 3
4import type { OAuthClientProvider } from "@modelcontextprotocol/client";4import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
5import { CallToolResultSchema } from "@modelcontextprotocol/core";5import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
6import * as z from "zod/v4";
6 7
7import {8import {
8 authToolFailure,9 authToolFailure,
@@ -60,7 +61,6 @@ import { · 51 unmodified lines
60 expandMcpAuthMethodInputs,61 expandMcpAuthMethodInputs,
61 mcpAuthMethodFromShorthand,62 mcpAuthMethodFromShorthand,
62 normalizeMcpAuthMethods,63 normalizeMcpAuthMethods,
63 McpStdioVersionNegotiation,
64 parseMcpIntegrationConfig,64 parseMcpIntegrationConfig,
65 type McpIntegrationConfig as McpIntegrationConfigType,65 type McpIntegrationConfig as McpIntegrationConfigType,
66 type McpStdioEnvMethod,66 type McpStdioEnvMethod,
@@ -207,11 +207,6 @@ const McpStdioServerInputSchema = Schema.Struct({ · 140 unmodified lines
207 * instead and leaves the values to the connect step. */207 * instead and leaves the values to the connect step. */
208 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),208 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
209 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),
215 slug: Schema.optional(Schema.String),210 slug: Schema.optional(Schema.String),
216});211});
217 212
@@ -375,7 +370,6 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 157 unmodified lines
375 command: input.command,370 command: input.command,
376 args: input.args ? [...input.args] : undefined,371 args: input.args ? [...input.args] : undefined,
377 cwd: input.cwd,372 cwd: input.cwd,
378 versionNegotiation: input.versionNegotiation,
379 authenticationTemplate:373 authenticationTemplate:
380 vars.length > 0374 vars.length > 0
381 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }]375 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }]
@@ -398,7 +392,7 @@ type JsonSchemaObject = Record<string, unknown> & { · 16 unmodified lines
398 readonly properties?: Record<string, unknown>;392 readonly properties?: Record<string, unknown>;
399};393};
400 394
401const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema();395const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject;
402 396
403const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {397const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {
404 const defaultStructuredContentSchema =398 const defaultStructuredContentSchema =
@@ -499,9 +493,7 @@ export const userFacingProbeMessage = ( · 94 unmodified lines
499// MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the493// MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the
500// transport sends it as a Bearer header. Refresh is core's responsibility494// transport sends it as a Bearer header. Refresh is core's responsibility
501// (the connection row carries the OAuth grant); this adapter never initiates495// (the connection row carries the OAuth grant); this adapter never initiates
502// a new flow and fails loudly if the SDK tries to. V2 stamps stored credentials496// a new flow and fails loudly if the SDK tries to.
503// with the authorization-server issuer and offers scoped invalidation; this
504// single-token boundary intentionally persists neither.
505// ---------------------------------------------------------------------------497// ---------------------------------------------------------------------------
506 498
507const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({499const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({
@@ -594,7 +586,6 @@ const buildConnectorInput = ( · 86 unmodified lines
594 args: config.args,586 args: config.args,
595 env: Object.keys(env).length > 0 ? env : undefined,587 env: Object.keys(env).length > 0 ? env : undefined,
596 cwd: config.cwd,588 cwd: config.cwd,
597 versionNegotiation: config.versionNegotiation,
598 } satisfies McpStdioIntegrationConfig);589 } satisfies McpStdioIntegrationConfig);
599 }590 }
600 591
@@ -1053,7 +1044,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 452 unmodified lines
1053 command: config.command,1044 command: config.command,
1054 args: config.args,1045 args: config.args,
1055 cwd: config.cwd,1046 cwd: config.cwd,
1056 versionNegotiation: config.versionNegotiation,
1057 authenticationTemplate: hasEnv1047 authenticationTemplate: hasEnv
1058 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }]1048 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }]
1059 : [{ slug: "none", kind: "none" }],1049 : [{ slug: "none", kind: "none" }],
packages/plugins/mcp/src/sdk/probe-shape.test.ts+2−90
@@ -1,12 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer, Ref } from "effect";2import { Effect, Ref } from "effect";
3import {3import { HttpServerResponse } from "effect/unstable/http";
4 HttpClient,
5 HttpClientError,
6 HttpClientRequest,
7 HttpClientResponse,
8 HttpServerResponse,
9} from "effect/unstable/http";
10import { serveTestHttpApp } from "@executor-js/sdk/testing";4import { serveTestHttpApp } from "@executor-js/sdk/testing";
11 5
12import { probeMcpEndpointShape } from "./probe-shape";6import { probeMcpEndpointShape } from "./probe-shape";
@@ -326,88 +320,6 @@ describe("probeMcpEndpointShape", () => { · 313 unmodified lines
326 ),320 ),
327 );321 );
328 322
329 it.effect("falls through a wrong-shape legacy GET retry to modern server discovery", () =>
330 Effect.scoped(
331 Effect.gen(function* () {
332 const server = yield* serveProbeEndpoint((request) => {
333 if (request.body.includes('"method":"server/discover"')) {
334 return HttpServerResponse.jsonUnsafe({
335 jsonrpc: "2.0",
336 id: 2,
337 error: { code: -32601, message: "Method not found" },
338 });
339 }
340 if (request.method === "GET") {
341 return HttpServerResponse.jsonUnsafe({ error: "legacy SSE is unsupported" });
342 }
343 return HttpServerResponse.empty({ status: 405 });
344 });
345
346 const result = yield* probeMcpEndpointShape(server.endpoint);
347 expect(result).toEqual({ kind: "mcp", requiresAuth: false });
348
349 const requests = yield* server.requests;
350 expect(requests).toHaveLength(3);
351 expect(requests[0]?.body).toContain('"protocolVersion":"2025-11-25"');
352 expect(requests[1]?.method).toBe("GET");
353 expect(requests[2]?.body).toBe(
354 JSON.stringify({
355 jsonrpc: "2.0",
356 id: 2,
357 method: "server/discover",
358 params: {
359 _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
360 },
361 }),
362 );
363 expect(requests[2]?.headers["mcp-protocol-version"]).toBe("2026-07-28");
364 }),
365 ),
366 );
367
368 // First request (initialize) answers 200 HTML; second (the discover
369 // fallback) dies at the transport. The endpoint already proved reachable,
370 // so the verdict must stay the initialize classification, not "unreachable".
371 it.effect("keeps the initialize verdict when the discover fallback fails at the transport", () =>
372 Effect.gen(function* () {
373 let requestCount = 0;
374 const httpClientLayer = Layer.succeed(HttpClient.HttpClient)(
375 HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
376 requestCount += 1;
377 if (requestCount > 1) {
378 return Effect.fail(
379 new HttpClientError.HttpClientError({
380 reason: new HttpClientError.TransportError({
381 request,
382 description: "connection reset by peer",
383 }),
384 }),
385 );
386 }
387 return Effect.succeed(
388 HttpClientResponse.fromWeb(
389 request,
390 new Response("<html>not mcp</html>", {
391 status: 200,
392 headers: { "content-type": "text/html" },
393 }),
394 ),
395 );
396 }),
397 );
398
399 const result = yield* probeMcpEndpointShape("https://internal.example/mcp", {
400 httpClientLayer,
401 });
402 expect(result).toEqual({
403 kind: "not-mcp",
404 category: "wrong-shape",
405 reason: "2xx POST body is not a JSON-RPC envelope",
406 });
407 expect(requestCount).toBe(2);
408 }),
409 );
410
411 it.effect("rejects 2xx with HTML body as wrong-shape", () =>323 it.effect("rejects 2xx with HTML body as wrong-shape", () =>
412 withServer(324 withServer(
413 () =>325 () =>
packages/plugins/mcp/src/sdk/probe-shape.ts+10−51
@@ -13,7 +13,7 @@ · 12 unmodified lines
13// and (b) plenty of real MCP servers authenticate with static API13// and (b) plenty of real MCP servers authenticate with static API
14// keys and publish no OAuth metadata at all (e.g. cubic.dev).14// keys and publish no OAuth metadata at all (e.g. cubic.dev).
15//15//
16// The primary probe issues an unauth JSON-RPC `initialize` POST and accepts16// The probe issues an unauth JSON-RPC `initialize` POST and accepts
17// only the wire shapes a real MCP server can return:17// only the wire shapes a real MCP server can return:
18//18//
19// - 2xx with `Content-Type: text/event-stream` — streamable HTTP19// - 2xx with `Content-Type: text/event-stream` — streamable HTTP
@@ -30,14 +30,8 @@ · 10 unmodified lines
30// only accepts 2xx with `text/event-stream` or the same 401+Bearer30// only accepts 2xx with `text/event-stream` or the same 401+Bearer
31// shape.31// shape.
32//32//
33// If initialize ultimately has the wrong shape, a second JSON-RPC POST probes33// One `fetch` (occasionally two), no MCP-SDK session state, no OAuth
34// `server/discover` using the 2026-07-28 envelope and protocol header. This34// round-trip, no DCR — every non-MCP endpoint exits here.
35// catches modern-only servers that reject initialize with a non-JSON-RPC
36// response. Authentication outcomes remain terminal because they do not vary
37// by transport era.
38//
39// One primary request (occasionally plus legacy GET and modern discover), no
40// MCP-SDK session state, no OAuth round-trip, no DCR.
41// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
42 36
43import { Data, Duration, Effect, Layer, Option, Schema } from "effect";37import { Data, Duration, Effect, Layer, Option, Schema } from "effect";
@@ -54,21 +48,12 @@ const INITIALIZE_BODY = JSON.stringify({ · 10 unmodified lines
54 id: 1,48 id: 1,
55 method: "initialize",49 method: "initialize",
56 params: {50 params: {
57 protocolVersion: "2025-11-25",51 protocolVersion: "2025-06-18",
58 capabilities: {},52 capabilities: {},
59 clientInfo: { name: "executor-probe", version: "0" },53 clientInfo: { name: "executor-probe", version: "0" },
60 },54 },
61});55});
62 56
63const DISCOVER_BODY = JSON.stringify({
64 jsonrpc: "2.0",
65 id: 2,
66 method: "server/discover",
67 params: {
68 _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
69 },
70});
71
72/** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s57/** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s
73 * `Response.headers` already lower-cases, but we normalise explicitly58 * `Response.headers` already lower-cases, but we normalise explicitly
74 * to stay robust against test mocks that construct `Headers` loosely. */59 * to stay robust against test mocks that construct `Headers` loosely. */
@@ -400,9 +385,10 @@ export const probeMcpEndpointShape = ( · 325 unmodified lines
400 .execute(postRequest)385 .execute(postRequest)
401 .pipe(Effect.timeout(Duration.millis(timeoutMs)));386 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
402 387
403 let initializeResult = yield* classify(postResponse, "POST");388 const postResult = yield* classify(postResponse, "POST");
389 if (postResult) return postResult;
404 390
405 if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) {391 if ([404, 405, 406, 415].includes(postResponse.status)) {
406 let getRequest = HttpClientRequest.get(url.toString()).pipe(392 let getRequest = HttpClientRequest.get(url.toString()).pipe(
407 HttpClientRequest.setHeader("accept", "text/event-stream"),393 HttpClientRequest.setHeader("accept", "text/event-stream"),
408 );394 );
@@ -412,42 +398,15 @@ export const probeMcpEndpointShape = ( · 3 unmodified lines
412 const getResponse = yield* client398 const getResponse = yield* client
413 .execute(getRequest)399 .execute(getRequest)
414 .pipe(Effect.timeout(Duration.millis(timeoutMs)));400 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
415 initializeResult = yield* classify(getResponse, "GET");401 const getResult = yield* classify(getResponse, "GET");
402 if (getResult) return getResult;
416 }403 }
417 404
418 initializeResult ??= {405 return {
419 kind: "not-mcp",406 kind: "not-mcp",
420 category: "wrong-shape",407 category: "wrong-shape",
421 reason: `unexpected status ${postResponse.status} for initialize`,408 reason: `unexpected status ${postResponse.status} for initialize`,
422 } as const;409 } as const;
423
424 if (initializeResult.kind !== "not-mcp" || initializeResult.category !== "wrong-shape") {
425 return initializeResult;
426 }
427
428 let discoverRequest = HttpClientRequest.post(url.toString()).pipe(
429 HttpClientRequest.setHeader("content-type", "application/json"),
430 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
431 HttpClientRequest.bodyText(DISCOVER_BODY, "application/json"),
432 );
433 for (const [name, value] of Object.entries(options.headers ?? {})) {
434 discoverRequest = HttpClientRequest.setHeader(discoverRequest, name, value);
435 }
436 discoverRequest = HttpClientRequest.setHeader(
437 discoverRequest,
438 "MCP-Protocol-Version",
439 "2026-07-28",
440 );
441
442 // The endpoint already answered the primary probe, so a transport
443 // failure on this secondary request must not overwrite that verdict
444 // with "unreachable" — keep the initialize classification instead.
445 const discoverResult = yield* client.execute(discoverRequest).pipe(
446 Effect.timeout(Duration.millis(timeoutMs)),
447 Effect.flatMap((discoverResponse) => classify(discoverResponse, "POST")),
448 Effect.catch(() => Effect.succeed(null)),
449 );
450 return discoverResult ?? initializeResult;
451 }).pipe(410 }).pipe(
452 Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),411 Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),
453 Effect.mapError(412 Effect.mapError(
packages/plugins/mcp/src/sdk/stdio-connector.ts+3−4
@@ -3,9 +3,8 @@ · 2 unmodified lines
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4//4//
5// Kept in its own module so `connection.ts` never imports it eagerly at5// Kept in its own module so `connection.ts` never imports it eagerly at
6// module load. The v2 `@modelcontextprotocol/client/stdio` entry still eagerly6// module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in
7// evaluates Node-only process/stream imports and `cross-spawn` (which loads7// `node:child_process` at evaluation time; under `@cloudflare/vitest-pool-workers`
8// `node:child_process`); under `@cloudflare/vitest-pool-workers`
9// that crashes workerd at module instantiation with SIGSEGV (prod bundles8// that crashes workerd at module instantiation with SIGSEGV (prod bundles
10// tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not).9// tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not).
11//10//
@@ -14,7 +13,7 @@ · 2 unmodified lines
14// the import and therefore never touch `node:child_process`.13// the import and therefore never touch `node:child_process`.
15// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
16 15
17import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";16import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
18 17
19export type StdioTransportConfig = {18export type StdioTransportConfig = {
20 readonly command: string;19 readonly command: string;
packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts+0−24
@@ -1,24 +0,0 @@
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+0−64
@@ -1,64 +0,0 @@
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/testing-fixtures.test.ts+3−5
@@ -1,6 +1,7 @@
1import { expect, layer } from "@effect/vitest";1import { expect, layer } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4import { OAuthTestServer } from "@executor-js/sdk/testing";5import { OAuthTestServer } from "@executor-js/sdk/testing";
5 6
6import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing";7import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing";
@@ -15,10 +16,7 @@ const createGreetingMcpServer = () => · 8 unmodified lines
15 });16 });
16 17
17const makeClient = (endpoint: string, accessToken: string) => {18const makeClient = (endpoint: string, accessToken: string) => {
18 const client = new Client(19 const client = new Client({ name: "executor-test-client", version: "1.0.0" });
19 { name: "executor-test-client", version: "1.0.0" },
20 { versionNegotiation: { mode: "auto" } },
21 );
22 const transport = new StreamableHTTPClientTransport(new URL(endpoint), {20 const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
23 requestInit: {21 requestInit: {
24 headers: { authorization: `Bearer ${accessToken}` },22 headers: { authorization: `Bearer ${accessToken}` },
packages/plugins/mcp/src/sdk/types.ts+0−17
@@ -31,20 +31,6 @@ 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
48// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
49// Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`)35// Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`)
50// plus MCP's own oauth variant. An integration declares zero or more methods,36// plus MCP's own oauth variant. An integration declares zero or more methods,
@@ -222,9 +208,6 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 171 unmodified lines
222 env: Schema.optional(StringMap),208 env: Schema.optional(StringMap),
223 /** Working directory */209 /** Working directory */
224 cwd: Schema.optional(Schema.String),210 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),
228 /** Declared auth methods — a single `stdio_env` method naming the secret env211 /** Declared auth methods — a single `stdio_env` method naming the secret env
229 * vars, or `none`. A connection's `template` picks one by slug, exactly as212 * vars, or `none`. A connection's `template` picks one by slug, exactly as
230 * for remote servers. Optional so pre-revamp stdio configs (which had no213 * for remote servers. Optional so pre-revamp stdio configs (which had no
packages/plugins/mcp/src/testing/server.ts+2−37
@@ -1,9 +1,8 @@
1import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect";1import { Context, Data, Effect, Layer, Ref, Scope } from "effect";
2import * as http from "node:http";2import * as http from "node:http";
3// Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients.3// Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients.
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";5import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
7import { OAuthTestServer } from "@executor-js/sdk/testing";6import { OAuthTestServer } from "@executor-js/sdk/testing";
8import z from "zod";7import z from "zod";
9 8
@@ -59,22 +58,6 @@ const writeText = (response: http.ServerResponse, status: number, body: string) · 49 unmodified lines
59 response.end(body);58 response.end(body);
60};59};
61 60
62const readRequestBody = (
63 request: http.IncomingMessage,
64): Effect.Effect<string, McpTestServerError> =>
65 Effect.tryPromise({
66 try: () =>
67 new Promise<string>((resolve, reject) => {
68 const chunks: Buffer[] = [];
69 request.on("data", (chunk: Buffer) => chunks.push(chunk));
70 request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
71 request.on("error", reject);
72 }),
73 catch: (cause) => new McpTestServerError({ cause }),
74 });
75
76const decodeJsonBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
77
78const isMcpPath = (url: string, path: string): boolean => {61const isMcpPath = (url: string, path: string): boolean => {
79 const parsed = new URL(url, "http://executor.test");62 const parsed = new URL(url, "http://executor.test");
80 return parsed.pathname === path;63 return parsed.pathname === path;
@@ -175,24 +158,6 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 94 unmodified lines
175 return;158 return;
176 }159 }
177 160
178 // Mirror the real v1 transport's stateful contract: only an
179 // `initialize` POST opens a session; any other sessionless POST
180 // (e.g. a v2 client's `server/discover` era probe) is rejected
181 // with 400 + a JSON-RPC error and no session is minted.
182 let parsedBody: unknown;
183 if (request.method === "POST") {
184 const body = yield* readRequestBody(request);
185 parsedBody = Option.getOrUndefined(decodeJsonBody(body));
186 if (!isInitializeRequest(parsedBody)) {
187 writeJson(response, 400, {
188 jsonrpc: "2.0",
189 error: { code: -32000, message: "Bad Request: Server not initialized" },
190 id: null,
191 });
192 return;
193 }
194 }
195
196 const transport = new StreamableHTTPServerTransport({161 const transport = new StreamableHTTPServerTransport({
197 sessionIdGenerator: () => crypto.randomUUID(),162 sessionIdGenerator: () => crypto.randomUUID(),
198 onsessioninitialized: (sid) => {163 onsessioninitialized: (sid) => {
@@ -208,7 +173,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 9 unmodified lines
208 catch: (cause) => new McpTestServerError({ cause }),173 catch: (cause) => new McpTestServerError({ cause }),
209 });174 });
210 yield* Effect.tryPromise({175 yield* Effect.tryPromise({
211 try: () => transport.handleRequest(request, response, parsedBody),176 try: () => transport.handleRequest(request, response),
212 catch: (cause) => new McpTestServerError({ cause }),177 catch: (cause) => new McpTestServerError({ cause }),
213 });178 });
214 }).pipe(179 }).pipe(
plans/kill-plugin-system.md+515−0
@@ -0,0 +1,515 @@
1# Kill the plugin system — in-place migration plan
2
3Decision (Rhys, 2026-07-27): abandon the big-bang rework
4(`UsefulSoftwareCo/executor-rework`, cloned read-only at
5`.reference/executor-rework`); fix this repo in place over a sequence of PRs,
6each keeping format/lint/typecheck/test/e2e green. Priority order: **data
7models first**, then the plugin kill, then the concept-package (apps) model,
8then the minimal SDK / platform split.
9
10RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now
11lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.**
12Rationale: the new model's tables are greenfield and additive — landing it
13first means the legacy apps tables never need an in-place migration; the whole
14model swaps underneath while openapi/mcp/graphql keep running as plugins.
15Structurally sound because (a) the kernel + MCP host are already plugin-free,
16so the new model's runtime never touched the registry, and (b) the apps plugin
17already exercises every extension point, so the new model ships as the apps
18plugin's new internals wearing the same costume. When 2.3 later converts the
19four kinds, apps converts as a stable, proven model instead of mid-rewrite.
20Phase 0 and 2.1 remain freely parallel — they don't touch apps.
21
22Hard line: side-by-side means new-apps-model alongside the _protocol plugins_,
23never two app models with dual read paths inside the apps kind. Existing app
24sources get a one-off migration to published-source form (enumerated
25invariants, explicit approval, no permanent compatibility path). Accepted
26cost: the new model's HTTP surface temporarily wears plugin-route ceremony
27(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not
28model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes
29public early; its boundaries take Standard Schema (StandardSchemaV1 &
30StandardJSONSchemaV1), never Effect Schema, and Phase 4's vocabulary rules
31(opaque owner refs, no "tenant") bind on it from day one.
32
33## Diagnosis (verified against the code)
34
35**The plugin system is a fiction.** `packages/core/sdk/src/plugin.ts` (~850
36lines, ~20 optional hooks) pretends the plugin set is open, but:
37
38- `Integration.kind` _is_ the plugin id string; all hot paths dispatch via
39 `runtimes.get(row.plugin_id)` (`executor.ts`).
40- Core and React hardcode the closed set anyway: `migration-spec.ts` hardcodes
41 plugin ids; `packages/react` has `KIND_TO_PLUGIN_KEY` maps and an
42 `if (pluginId === "toolkits")` route branch.
43- Nobody ever shipped a fourth protocol plugin, and the two provider plugins
44 (google/microsoft) were already collapsed into openapi (#1366).
45- Whole packages exist only to support the open set: `plugin-routes.ts`
46 type-union machinery, `@executor-js/config`'s jsonc+jiti loader,
47 `packages/core/vite-plugin`'s virtual client module. The jsonc plugin path is
48 vestigial — no `executor.jsonc` exists anywhere in the repo, no docs mention
49 it.
50- Toolkits (~880 LoC of pure curation + the single `toolPolicyProvider` slot)
51 and apps (~6.8k custom-tools gateway) are first-party capabilities wearing a
52 plugin costume. Kernel packages and `packages/hosts/mcp` have zero plugin
53 references — the indirection is not load-bearing for the real surfaces.
54
55**The data model is connection-first, not user-first**
56(`packages/core/sdk/src/core-schema.ts`):
57
58- No user/subject table. `subject` is an opaque partition-key string smeared
59 across owned tables; a user "exists" only if they have a connection row.
60- Disconnect is a hard delete (`connectionsRemove`) — a signed-out user is
61 bit-identical to one who never connected. "Which users are signed out" is
62 structurally unanswerable.
63- Every Executor is bound to one `{tenant, subject}` via `owner-policy.ts`;
64 there is no cross-subject read path, so no admin view.
65- Toolkit state lives in generic `plugin_storage` KV, not core tables.
66- Cloud's real identity tables (WorkOS accounts/organizations) have no join to
67 `subject`.
68
69**What to salvage from the rework** (its model docs are the valuable artifact;
70its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it
71failed, and why this plan is incremental):
72
73- Apps-as-published-source: `defineApp`/`defineProvider`/`defineTool`/
74 `defineToolGroup`, immutable content-hashed versions, manifest derived by
75 executing the source, rollback = pointer move.
76- Generators: OpenAPI/MCP specs generate the _same_ app source a human would
77 write, with a re-verifiable `GeneratorClaim` (first-party vs third-party).
78- Minimal SDK boundary: `createExecutor`/`createRemoteExecutor`, no Effect
79 crossing the boundary, MCP exposure returns a standard MCP SDK server.
80- Custody separation: secrets never on the wire; opaque owner refs.
81
82## Constraints discovered (do not relearn these)
83
841. **Published packages make this semver-major.** `scripts/publish-packages.ts`
85 publishes `@executor-js/{config,sdk}` and plugins
86 `{example,file-secrets,graphql,keychain,mcp,onepassword,openapi}` — killing
87 `definePlugin` is a breaking release for seven packages. Changesets +
88 deprecation story required; `publish-packages.ts`, `pkg-pr-new.yml`,
89 `check-changelog-stubs.ts`, and `examples/*` all have hardcoded lists.
902. **`plugin_id = 'google'` is a live persisted value with no runtime.** The
91 provider-service-split migration skips orgs with missing spec blobs
92 (`sqlite.test.ts` asserts survivors). The `googleDiscovery → "google"`
93 entries in React are compensating, not dangling. The closed SourceType
94 switch must map `"google"`/`"googleDiscovery"` → openapi or degraded rows
95 become hard failures.
963. **FumaDB is not versioned in practice.** All hosts pin `version: "1.0.0"`;
97 runtime bring-up is `ensureDrizzleRuntimeSchemaFromTables` (CREATE TABLE IF
98 NOT EXISTS + additive nullable ALTERs). New tables/nullable columns are free
99 on local/self-host/D1. **Cloud is the exception**: hand-edit
100 `apps/cloud/src/db/executor-schema.ts` + drizzle-kit migration +
101 `db:migrate:prod`; `db.schema.test.ts` enforces the mirror, so "forgot
102 cloud" fails tests.
1034. **93 e2e files import `composePluginApi`** purely to type the client. Keep
104 it as a deprecated argument-ignoring shim returning the static
105 `FullExecutorApi`; sweep the call sites mechanically in one final PR.
1065. **Keep the `plugin_id` column name** (four tables + blob namespace format
107 `o:<tenant>/<plugin_id>`). Renaming to `source_kind` is a cross-host
108 migration for zero behavior. Retype on the read side only.
1096. **URL contracts:** `/integrations/add/$pluginKey` is navigated directly by
110 e2e; `/plugins/desktop-settings/` is asserted by the desktop-packaged suite
111 and baked into a tool's returned URL. Keep shapes / add redirects.
1127. **CI gaps:** PRs run cloud (4 shards) + selfhost e2e only; local/desktop
113 suites gate push-to-main. PRs touching desktop-settings or the local loader
114 need a manual local e2e run pre-merge.
1158. **Toolkit selection filters _connections_, not just tools**
116 (`connectionsList` goes through the active policy provider). Preserve when
117 moving toolkits to core, or the toolkit e2e suites go red.
118
119## Phase 0 — declutter (3 PRs)
120
121- **0.1** Delete empty `packages/plugins/{google,microsoft}`; drop the unused
122 `@executor-js/config` dep from openapi/mcp/graphql; move
123 `provider-service-split` to `packages/migrations/` (keep package name — it's
124 imported by three host data-migration registries). In the React maps, keep
125 `google`/`googleDiscovery` → openapi with a comment citing constraint 2.
126- **0.2** Bake google/microsoft catalogs + spec-format adapters in as openapi
127 defaults; strip the re-threading from all four host configs. Gate:
128 `provider-plugins-ui`, `google-health-checks`, `microsoft-*` scenarios.
129- **0.3** Move desktop-settings (438-line Electron-IPC client, not a
130 one-liner) to a first-class desktop-only route in `packages/app`; update the
131 static tool's URL and the desktop-packaged e2e selector; delete the plugin.
132
133## Phase 1 — data model (4 PRs) — FIRST PRIORITY
134
135Design choices (least-invasive, verified feasible):
136
137- The new **subject table is `tenant`-scoped, not owner-scoped** — readable by
138 any executor bound to the tenant with **zero policy changes** (the
139 `integration` table already proves the pattern).
140- Cross-subject connection reads: add `reach: "bound" | "tenant"` to
141 `ExecutorOwnerPolicyContext` — widens `ownerVisibilityCondition` for reads
142 only; `assertOwnerWritable`/`assertOwnerPatch` untouched, so admin reach can
143 never write. Only two construction sites exist (`executor.ts`,
144 `test-config.ts`).
145- Identity stays with the hosts (WorkOS / Better Auth member lists already
146 exist behind `AccountProvider.listMembers`); the subject table is the missing
147 **join** between host identity and executor connections, not a new identity
148 system. Tolerate `subject = null` (pure-org) and `subject = "local"`.
149
150PRs:
151
152- **1.1** Add tenant-scoped `subject` table (id, tenant, external_id,
153 created_at, last_seen_at, status) to `coreTables` + cloud mirror + one
154 drizzle migration. No readers yet.
155- **1.2** Populate: upsert in `makeScopedExecutor`
156 (`packages/core/api/src/server/scoped-executor.ts` — every HTTP request and
157 MCP session on all four hosts passes through it) and at connection-create.
158 Add to `purgeOrganizationData` + its `TENANT_TABLES` test.
159- **1.3** `reach` mode + `admin.listSubjectsWithConnections`-shaped read on a
160 derived handle. Policy test proving admin reach cannot write. Scope includes
161 (customer ask, owner.com 2026-07-27): per-subject reads ("connections for
162 subject X"), not just the aggregate list, and a privileged org-level API key
163 (keys already carry `{accountId, organizationId}` —
164 `apps/cloud/src/auth/api-keys.ts`) that resolves to the tenant-reach
165 read-only handle so customers can inspect connections programmatically.
166- **1.4** Connection lifecycle: nullable `status` column
167 (active/disconnected/revoked/expired) plus `status_changed_at`; disconnect
168 writes status instead of deleting. Wide blast radius — status predicate
169 through `findConnectionRow`, `connectionsList`, credential resolution,
170 `toolsList`, org purge; own e2e sweep (`connections-*`, `health-checks*`,
171 `oauth-*`). PRIORITY RAISED (customer, 2026-07-28): the customer's
172 block-startup-until-reconnect flow needs connected-then-disconnected to be
173 distinguishable from never-connected, which hard-delete makes impossible
174 today; expose status + statusChangedAt through the admin API when this
175 lands.
176
177Toolkits data does NOT move in this phase (it moves with its reader in 2.4 —
178moving it earlier means either rewriting the plugin storage layer twice or a
179forbidden dual read path).
180
181### Framing: product view vs platform view (Rhys, 2026-07-27)
182
183The two-view model for consumer-facing surfaces, longer-term:
184
185- **Product view** — the bound, per-individual executor every request gets
186 today (their connections, their tools). The default; unchanged.
187- **Platform view** — an operator-level view attached alongside it: read-only,
188 cross-user, explicitly reached for with elevated access (an escape hatch,
189 never a mode product view drifts into). The `reach: "bound" | "tenant"`
190 split in 1.3 is this model's first implementation; 1.5's admin API and the
191 privileged key are its first surface. Later platform-view candidates (usage,
192 policy overview, cross-user health) land on the same handle.
193
194Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids —
195their users authenticate through our WorkOS, so `subject` is _our_ accountId
196and the customer correlates on their side. The opaque-owner-ids-in-the-
197customer's-id-space story fully applies only to embedded/API consumers; for
198cloud, the platform view (+ `external_id` on the subject table) is what makes
199the correlation tractable.
200
201### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone)
202
203The owner.com ask (admin inspects users' connections; API key that can do the
204same; available-vs-connected icon grid; direct connect links) needs only
2051.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the
206plugin kill. Delivery PRs on top of 1.3:
207
208- **1.5** Public admin API surface: `GET .../admin/users`,
209 `GET .../admin/users/:externalId/connections` (integration slug + status +
210 last_health only — never item_ids/oauth fields), gated on the privileged key
211 / admin membership. Public paths say `users`, not `subjects`/`tenants` — see
212 "Vocabulary" below. The available-vs-connected diff is computable client-side
213 from this plus the existing integration catalog.
214- **1.6** Connect deep links: stable
215 `/connect/:integrationSlug`-shaped URL that lands the arriving user in the
216 connect flow for that integration (auth-aware redirect). Uses integration
217 slugs, not plugin routes, so it survives Phase 2.5 unchanged.
218
219 1.4 (lifecycle status) upgrades the grid from binary connected/not to
220 expired/disconnected states but is not a blocker for first ship.
221
222## Phase 2 — kill the registry (6 PRs)
223
224- **2.1** Remove dynamic loading: delete `load-plugins.ts` from
225 `@executor-js/config` (keep the rest — jsonc integration config is a separate
226 feature), drop `loadPluginsFromJsonc` from `apps/local`, delete
227 `packages/core/vite-plugin`, replace `virtual:executor/plugins-client` with a
228 static module in `packages/app` (5 `__root.tsx` importers + 3 ambient
229 `.d.ts`), edit `turbo.json` `dev.dependsOn` + root `prepare`, fix
230 `packages/app/vite.config.ts`'s dangling jsonc path. Breaking changeset.
231- **2.2** Static route list: `composePluginApi` becomes the deprecated shim
232 (constraint 4); hosts stop passing plugin tuples; delete
233 `apps/{cloud,host-selfhost}/src/plugins.ts` (exist only to hold
234 `PluginExtensionServices` typing).
235- **2.3** The switch: closed internal `SourceType` interface implemented by
236 openapi/graphql/mcp/**apps (current model, unchanged)** as plain libraries,
237 replacing `runtimes.get`. All four kinds convert together — converting three
238 and leaving apps on the plugin path means carrying dual dispatch; rewriting
239 the apps model in the same PR is what killed the rework. Map
240 `"google"`/`"googleDiscovery"` → openapi. `plugin_id` column name unchanged.
241- **2.4** Toolkits to core: core tables + one-off migration out of
242 `plugin_storage` (enumerated invariants, no dual path), `toolPolicyProvider`
243 slot → native policy input (one call site, one implementation, one test),
244 ordinary core routes, `activeToolkitSlug` becomes an executor option.
245 Preserve the connection-visibility filter (constraint 8).
246- **2.5** React: four named kind renderers; move plugin React code (~2.9k LoC,
247 e.g. `AddMcpIntegration.tsx`) into `packages/react` — watch the peer-dep
248 edge for cycles; delete `defineClientPlugin`, the plugins context, both
249 `KIND_TO_PLUGIN_KEY` maps, and `/plugins/$pluginId/$` (with redirects for
250 desktop-settings + toolkits). Keep `/integrations/add/$pluginKey` URL shape.
251 Don't forget the second axis: `useSecretProviderPlugins` (secrets page).
252- **2.6** Detach `CredentialProvider` from `PluginSpec` (the interface in
253 `provider.ts` is already narrow — secret-store plugins keep it as their
254 seam); delete `plugin.ts`, `plugin-routes.ts`, `plugin-example`, and the
255 shim; mechanical sweep of the 93 e2e files; changesets for all seven
256 published packages.
257
258## Phase 3 — custom = concept package (3–4 PRs) — NOW BEFORE PHASE 2
259
260(See RESEQUENCED note at top: lands side by side with the protocol plugins,
261inside the apps plugin's existing costume; Phase 2 kills the registry
262afterward, with apps converting in 2.3 as an already-stable model.)
263
264Port the rework's app model in place as the `apps` source kind's new model:
265published source, manifest derived by execution, content-hashed immutable
266versions, rollback = pointer move. New tables are additive greenfield (same
267pattern as 1.1: coreTables + cloud mirror + migration, no readers first).
268Existing app sources migrate one-off to published-source form — no dual model
269within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an
270optional on-ramp (spec → generated app source); GraphQL generator later.
271Long-term option (explicitly out of scope): protocol kinds themselves become
272generators and "custom" is the only kind.
273
274### Feasibility findings (two deep read-throughs, 2026-07-28)
275
276**Current plugin** (~7.6k LoC): only ~2.65k encodes the old model (store
277collections, sync state machine, publish/discover/descriptor, routes,
278console client). ~2.6k is model-agnostic and survives verbatim: all three
279sandbox executors, the bundler stack, bindings/bridge, authoring +
280standard-schema, conformance. ~1.9k (hand-rolled git client + source
281fetchers) hangs on the publish-input decision below. Storage is entirely
282generic `plugin_storage` rows + content-addressed blobs — zero apps-specific
283tables or migrations exist, so new tables are pure greenfield. Cloud prod is
284git-source-only; **source text is never persisted today (only compiled
285bundles)**, so the one-off migration re-fetches source from the recorded git
286URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses
287`tools.<app>.org.published.<name>` + the `<fileSlug>__<key>` naming rule,
288app-slug↔integration-slug 1:1 guard, publish CAS
289(`AppPublishConflictError`), tombstone-not-delete, token-in-provider-item
290(never in the source row). Safety net: `custom-tools.test.ts` (534-line
291cloud+selfhost console loop), local directory-picker, packed-CLI, desktop
292e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never
293GC'd and org purge misses them; cloud silently falls back to in-process
294executor without the worker loader; host-cloudflare doesn't register apps.
295
296**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK,
297publish-by-execution, projection, generator verification, storage port,
298content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP
2990.6k — computed group with real streamable-HTTP client; json-schema engine;
300byte-for-byte GeneratorClaim verification; render endpoint); T4 host/registry
301~2.9k explicitly skipped. Authoring is Standard Schema
302(StandardSchemaV1 & StandardJSONSchemaV1) with raw-JSON-Schema literals as a
303typed first-class alternative — matches our rule. Effect beta.93→beta.59
304delta is small (ConstraintDecoder → one-line fix; hand-roll
305StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is:
306rework `storage-db.ts` (write a new adapter over our tables against the
307~90-line Storage port interface) and `engine.ts` (extract only ~700 LoC of
308model-owned logic: computed-surface cache + requirement resolution +
309runTool). Known rework gaps to fix at port time, not inherit: derive-time
310execution is unsandboxed in-process (`code-executor.ts:23` records it) — our
311port MUST run publish-time evaluation in the existing sandbox `collect` path
312from day one; fan-in computed groups unsupported; OAuth discovery
313unsupported.
314
315**Semantic deltas — decided by Rhys before 3.1 specs:**
316
3171. **Handler credential contract (the deep one).** Current: handlers get an
318 opaque callable `AppIntegrationClient` proxy that re-enters
319 `ctx.execute` — app code never sees credentials, can only call other
320 executor tools. Rework (D49): engine resolves credentials once and hands
321 the handler **credential fields as data** (`ResolvedConnection.fields`,
322 e.g. access_token) for direct HTTP — that's what makes generators
323 possible (generated code does raw fetch with auth) and what
324 `defineProvider` exists for. Not either/or in principle (a tool-call
325 bridge can coexist as a second requirement kind later), but the ported
326 model's primary contract is fields-as-data per the rework.
3272. **Publish input.** Rework publish takes `SourceFiles` (push-based).
328 Decision: keep git/local-directory as _fetchers that produce
329 SourceFiles_ (preserves prod sources, the e2e suite, and the migration
330 path; git-client survives), with direct push publish added as the new
331 API. Delete-git-later stays open.
3323. **App identity** moves from caller-supplied slug to the `defineApp`
333 export inside the source (rework shape).
3344. **Versioning**: content hash of source IS the version identity; the
335 caller-supplied `sourceRef` + descriptor v6 die with the old model.
3365. **Ownership**: app/version rows carry `space` and NO owner — spaces
337 curate apps, principals own custody (connections stay on the legacy
338 owner model in this phase; the opaque-OwnerRef swap is Phase 4).
339
340### Phase 3 PR sequence (revised)
341
342- **3.1** Port the model core as internal packages, no readers: authoring
343 SDK + runtime brands, publish pipeline (evaluation routed through the
344 sandbox `collect` seam), Storage port + adapter, greenfield tables
345 (`app`, `app_version`, `computed_surface` — space column, content-hash
346 identity, `current` pointer) in coreTables + cloud mirror + migration.
347 Ported rework tests come along (authoring-boundary, tool-groups,
348 publish).
349- **3.2** Swap the apps plugin's internals onto the new model behind the
350 same costume: resolveTools/projectToolSchema/invokeTool read the new
351 tables; publish endpoint (`POST` source files); setCurrent/rollback +
352 versions listing routes; git/local-dir sync becomes fetch→publish.
353 Tool addresses and slug guards preserved; old collections
354 (`apps_descriptors`/`apps_tools`/`apps_sources`) still written by
355 NOTHING — one-off migration converts existing sources (re-fetch from
356 git), then the collections are dead. e2e: full existing custom-tools
357 suite green + new version-history/rollback scenario.
358- **3.3** Console UI for the new model (sources → apps with versions,
359 publish status, rollback) replacing the ~1.3k LoC old client/panels.
360- **3.4** Generators: fromOpenAPI + fromMCP + GeneratorClaim verification +
361 render endpoint, surfaced as the "add from spec/server" on-ramp.
362 (Vendors google/microsoft conversion ~1.15k LoC ports later with 0.2.)
363
364## Spaces: the rework's tenancy primitive (added Rhys, 2026-07-28)
365
366The rework's SDK has exactly one tenancy concept: `SpaceId`
367(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as
368the end-state tenancy model here. The shape:
369
370- **Flat, no hierarchy, no default.** No parent, no inheritance, no
371 distinguished rows (rework D39/D44b). Sharing is an explicit act, never a
372 tree walk (R2 — re-ruled twice, do not relitigate). Every scoped row
373 carries a `space` column; every space-scoped route carries the space in
374 the URL path, never ambient context.
375- **No org/project/team/user below the deployment edge.** The engine answers
376 "what is in this space"; the deployment answers "who is this and which
377 spaces may they touch" through named seams (`authorizeBrowserResource`,
378 `dashboardSpacesForPrincipal`, MCP space resolver). Membership is
379 authorized LIVE per request at the edge — projection may be cached,
380 authorization never.
381- **Deployment mappings:** cloud = one WorkOS org mirrors lazily to one
382 space (mirror row `workosOrgId → space` is also the billing join — Autumn
383 keys on workosOrgId, never a space); self-host = one instance, one
384 found-or-created "Default" space, Better Auth org repurposed as the
385 instance membership table; local/desktop = one "local" space, one static
386 principal, single-tenancy declared via explicit sentinel, not defaulted.
387- **Spaces curate, principals own.** Custody (secrets, connections'
388 secret-bearing halves) belongs to principals via opaque `OwnerRef` +
389 per-request `Reach` (R3); spaces curate apps and hold rows. Publishing an
390 app into a space IS its presence there — no installation rows, no
391 bindings; cross-space arrival (sharing/registry) is a future share-edge
392 row, not golden-path ceremony (D45/D42).
393- Cross-org OAuth callbacks complete into the _initiating_ org's space; the
394 org header is a routing hint, not an authorization statement (R7).
395
396**DECIDED (Rhys, 2026-07-28): spaces land in the CORE PRODUCT NOW — Phase S,
397before the apps port.** Apps then publishes into real spaces from birth.
398
399**Mapping onto this repo — the no-migration trick:** today's `tenant` column
400is de facto the space — cloud writes the WorkOS org id there, self-host/local
401write one constant. So: keep the persisted `tenant` column name (same rule as
402`plugin_id`), and declare that **its value IS the space id**. Each org's
403first space is grandfathered with `spaceId = <historical tenant value>` (the
404org id); NEW spaces mint fresh ids written into the same column. The org→
405space mirror table records which space ids belong to which org (and carries
406the billing join — Autumn keys on workosOrgId, never a space). Existing rows
407never move; multi-space needs no schema change on scoped tables. "Space" is
408contract vocabulary; "tenant" retreats to cloud-internal (see Vocabulary).
409
410### Phase S — spaces in core (before apps)
411
412- **S1 — space table + boot ensure (no readers).** Greenfield `space` table
413 (id, name, created_at) in coreTables + cloud mirror + migration, plus the
414 cloud `org→space` mirror table. Boot/first-contact ensures the degenerate
415 space per deployment: cloud lazily on first authenticated org contact
416 (grandfathered id = org id), self-host "Default", local "local". Same
417 no-readers pattern as PR 1.1.
418- **S2 — space threading.** `SpaceId` brand in the SDK; the scoped-executor
419 seam resolves request → space id (today: identical to tenant) and stamps
420 it; reads interpret `tenant` columns as space ids. No behavior change —
421 this is the retype + vocabulary PR. `reach: "tenant"` reads as
422 "space-wide reach" from here on.
423- **S3 — multi-space on cloud + product surface.** Create/rename space,
424 space switcher in the console, membership = org membership (all spaces of
425 the org for now), org→N mirror rows, space-scoped catalog/connection
426 views (rows follow automatically — they're keyed by the space id in the
427 tenant column). Billing stays org-keyed via the mirror. e2e: two spaces
428 in one org, isolation of catalogs/connections between them, switcher.
429- **S4 — sharing model on spaces.** Connect-time "share with space" writes
430 the space's shared owner-ref (replaces the org tier semantically; the
431 persisted `owner: "org"|"user"` column keeps its name, read-side
432 reinterpretation only). Edge computes reach as
433 [principal, sharedRef(space) for each member space]. This is the bridge
434 to Phase 4's full owner-set reach.
435- **Later (not scheduled): local multi-space, file-backed.** Each local
436 space is its own store file (portable — copy/backup/sync/check in
437 alongside a repo); the daemon opens one storage handle per space and
438 routes by space id; the engine still sees flat SpaceIds. Custody never
439 enters the space file — secrets stay in the principal-keyed provider
440 (keychain), so a copied file carries connection metadata, never tokens.
441- Phase 4 completes the picture: space-in-path routes,
442 `dashboardSpacesForPrincipal`-style seams, reach as an explicit owner
443 set.
444
445Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK
446from birth; S3/S4 can proceed in parallel with the apps port.
447
448Interaction with shipped Phase 1 work: the subject table and platform view
449are edge/deployment concepts (who exists in this identity world, what may
450an operator read) — they sit above spaces and survive unchanged; `reach:
451"tenant"` becomes "space-wide reach" vocabulary-wise at Phase 4.
452
453## Vocabulary: retire "tenant" from consumer-facing surfaces (Rhys, 2026-07-27)
454
455Direction: the SDK should be agnostic to the consumer's data-model shape, the
456way Composio's is — consumers pass an opaque user/owner id string and hang
457whatever hierarchy they have (user, team, workspace) off it; the SDK never
458models their org chart. The rework already designed this (`OwnerRef` opaque
459brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt
460its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept
461(which Executor-cloud org owns the rows) but must not leak into surfaces
462consumers touch.
463
464Rules, effective immediately:
465
466- Persisted column names (`tenant`, `subject`, `owner`) stay — renaming
467 storage is a cross-host migration for zero behavior (same rule as
468 `plugin_id`).
469- Internal policy code (`reach: "tenant"`, `ExecutorOwnerPolicyContext`) may
470 keep the words.
471- One contract noun: **`owner`** — the opaque ref naming who a connection
472 belongs to (end-user, team, workspace, or the org itself; the rework's
473 `OwnerRef`). "User" cannot be the contract noun because org-shared
474 connections are a load-bearing feature an end-user word can't cover.
475- **"user" is presentation vocabulary only**, for surfaces whose rows genuinely
476 are the customer's end-users — the Phase 1.5 admin API says `users` because
477 the subject table is populated from authenticated principals.
478- Hazard: the persisted `owner` column keeps its old meaning (the
479 `"org" | "user"` tier discriminator), while Phase 4 introduces the opaque
480 ref as a distinctly named type (`OwnerRef`). Same word, two meanings,
481 separated by layer — say so in policy-layer comments.
482- The full swap (opaque owner refs at the SDK boundary, tenant retreating to a
483 cloud-internal detail) lands with the Phase 4 minimal SDK.
484
485## Phase 4 — minimal SDK + platform/product split
486
487New consumer boundary modeled on the rework: `createExecutor` /
488`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` +
489connections + the new users/admin surface; policies/toolkits become optional
490layers consumers opt into, not baked opinions (Composio-style minimalism).
491
492Authorization end-state (Rhys, 2026-07-27): the engine enforces scope
493mechanically but never decides policy — the host computes, per request, the
494set of owners the request may read (the rework's `Reach = { owners }`), and
495the storage predicate is just `row.owner ∈ reach.owners`. The 1.3
496`reach: "bound" | "tenant"` enum is a stepping stone and is replaced by this
497set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so
498cloud needn't enumerate), and in-between cases (team views, support sessions,
499subset-restricted org keys) are just different sets. Writes stay hard-pinned
500to a single owner; only read reach generalizes. Trust model becomes a
501per-deployment choice: a trusted backend with a server key gets tenant-wide
502reach and layers its own authorization (Composio-style); an end-user session
503gets a reach of one. The enum-to-set swap is contained: the policy context is
504constructed in exactly two places.
505Start the platform/product package separation _during_ 2.3–2.6 (each PR moves
506one thing to its final home) rather than as a big bang; the SDK boundary
507itself lands last.
508
509## Bookkeeping
510
511- Create `MISTAKES.md` / `DESIRES.md` / `LEARNINGS.md` (required by AGENTS.md,
512 currently missing) with the first entries from this migration.
513- `apps/local/src/db/executor-schema.ts` is already stale vs `coreTables`
514 (missing several columns); its "keep in sync" comment is untrustworthy —
515 runtime schema comes from the ensure path, not this file.