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
728728 "@executor-js/react": "workspace:*",
729729 "@executor-js/runtime-quickjs": "workspace:*",
730730 "@modelcontextprotocol/ext-apps": "^1.7.4",
731− "@modelcontextprotocol/sdk": "^1.29.0",
731+ "@modelcontextprotocol/sdk": "^1.12.1",
732732 "@tanstack/react-query": "^5.99.0",
733733 "effect": "catalog:",
734734 "esbuild": "^0.27.7",
@@ -1001,8 +1001,6 @@ · 266 unmodified lines
10011001 "@effect/platform-node": "catalog:",
10021002 "@executor-js/config": "workspace:*",
10031003 "@executor-js/sdk": "workspace:*",
1004− "@modelcontextprotocol/client": "2.0.0",
1005− "@modelcontextprotocol/core": "2.0.0",
10061004 "@modelcontextprotocol/sdk": "^1.29.0",
10071005 "zod": "4.3.6",
10081006 },
@@ -1011,7 +1009,6 @@ · 2 unmodified lines
10111009 "@effect/vitest": "catalog:",
10121010 "@executor-js/api": "workspace:*",
10131011 "@executor-js/react": "workspace:*",
1014− "@modelcontextprotocol/server": "2.0.0",
10151012 "@types/node": "catalog:",
10161013 "@types/react": "catalog:",
10171014 "bun-types": "catalog:",
e2e/local/stdio-mcp.test.ts+0−30
@@ -146,36 +146,6 @@ scenario( · 145 unmodified lines
146146 declTools.map((t) => t.name),
147147 "connecting with the secret discovers the env-gated tool",
148148 ).toContain("whoami");
149−
150− // --- versionNegotiation "auto" survives the API → config → connector
151− // path and still reaches a legacy server: the probe gets the fixture's
152− // method-not-found for `server/discover` (a definitive legacy verdict)
153− // and falls back to `initialize`. Modern-era acceptance against a real
154− // legacy-disabled SDK v2 server lives in the plugin's
155− // stdio-negotiation.test.ts. ---
156− const autoSlug = "e2e-stdio-auto";
157− yield* client.mcp.addServer({
158− payload: {
159− transport: "stdio",
160− name: "E2E Stdio Auto",
161− command: "node",
162− args: [FIXTURE],
163− versionNegotiation: "auto",
164− slug: autoSlug,
165− },
166− });
167−
168− const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
169− expect(
170− JSON.stringify(autoStored?.config ?? {}),
171− "the negotiation mode is persisted on the integration config",
172− ).toContain('"versionNegotiation":"auto"');
173−
174− const autoTools = yield* client.tools.list({ query: { integration: autoSlug } });
175− expect(
176− autoTools.map((t) => t.name),
177− "auto negotiation falls back to legacy and still discovers tools",
178− ).toContain("echo_tool");
179149 }),
180150 );
181151 }),
packages/hosts/mcp-apps-shell/package.json+1−1
@@ -69,7 +69,7 @@ · 68 unmodified lines
6969 "@executor-js/react": "workspace:*",
7070 "@executor-js/runtime-quickjs": "workspace:*",
7171 "@modelcontextprotocol/ext-apps": "^1.7.4",
72− "@modelcontextprotocol/sdk": "^1.29.0",
72+ "@modelcontextprotocol/sdk": "^1.12.1",
7373 "@tanstack/react-query": "^5.99.0",
7474 "effect": "catalog:",
7575 "esbuild": "^0.27.7",
packages/plugins/mcp/package.json+0−3
@@ -65,8 +65,6 @@ · 64 unmodified lines
6565 "@effect/platform-node": "catalog:",
6666 "@executor-js/config": "workspace:*",
6767 "@executor-js/sdk": "workspace:*",
68− "@modelcontextprotocol/client": "2.0.0",
69− "@modelcontextprotocol/core": "2.0.0",
7068 "@modelcontextprotocol/sdk": "^1.29.0",
7169 "zod": "4.3.6"
7270 },
@@ -75,7 +73,6 @@ · 2 unmodified lines
7573 "@effect/vitest": "catalog:",
7674 "@executor-js/api": "workspace:*",
7775 "@executor-js/react": "workspace:*",
78− "@modelcontextprotocol/server": "2.0.0",
7976 "@types/node": "catalog:",
8077 "@types/react": "catalog:",
8178 "bun-types": "catalog:",
packages/plugins/mcp/src/api/group.ts+0−4
@@ -57,10 +57,6 @@ const AddStdioServerPayload = Schema.Struct({ · 56 unmodified lines
5757 /** One-shot secret env values (programmatic). The UI sends `envVars`. */
5858 env: Schema.optional(StringMap),
5959 cwd: Schema.optional(Schema.String),
60− /** Protocol negotiation at connect: `auto` probes `server/discover` (spec
61− * 2026-07-28) for modern-only servers; default is the legacy `initialize`
62− * handshake. */
63− versionNegotiation: Schema.optional(Schema.Literals(["legacy", "auto"])),
6460 slug: Schema.optional(Schema.String),
6561 });
6662
packages/plugins/mcp/src/api/handlers.ts+0−2
@@ -39,7 +39,6 @@ const toServerInput = ( · 38 unmodified lines
3939 envVars?: readonly string[];
4040 env?: Record<string, string>;
4141 cwd?: string;
42− versionNegotiation?: "legacy" | "auto";
4342 slug?: string;
4443 };
4544 return {
@@ -51,7 +50,6 @@ const toServerInput = ( · 5 unmodified lines
5150 envVars: p.envVars ? [...p.envVars] : undefined,
5251 env: p.env,
5352 cwd: p.cwd,
54− versionNegotiation: p.versionNegotiation,
5553 slug: p.slug,
5654 };
5755 }
packages/plugins/mcp/src/sdk/connection-pool.ts+0−4
@@ -3,10 +3,6 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines
33 import type { McpConnection, McpConnector } from "./connection";
44 import type { McpInvocationError } from "./errors";
55
6−// The pool preserves sessions for sessionful legacy servers. Stateless
7−// 2026-07-28 servers do not need it, but retaining a cheap idle client is
8−// harmless and keeps one lifecycle for both protocol eras.
9−
106 const IDLE_TTL_MS = 5 * 60 * 1_000;
117
128 type IdleConnection = {
packages/plugins/mcp/src/sdk/connection.ts+11−35
@@ -1,18 +1,16 @@
1−import {
2− Client,
3− SSEClientTransport,
4− StreamableHTTPClientTransport,
5− type FetchLike,
6− type OAuthClientProvider,
7−} from "@modelcontextprotocol/client";
8−import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
1+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5+import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
6+import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
97 import { Effect, Layer, Predicate, Stream } from "effect";
108 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
119
1210 // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module
13−// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and
14−// `cross-spawn` eagerly at evaluation time, which crashes workerd (including
15−// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set
11+// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process`
12+// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at
13+// SIGSEGV on module instantiation. Cloud callers set
1614 // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;
1715 // prod bundles that DO use stdio load it via a dynamic import inside the
1816 // stdio branch of `createMcpConnector`.
@@ -203,13 +201,12 @@ const fetchFromHttpClientLayer = ( · 184 unmodified lines
203201 // MCP plugin runs inside a Cloudflare Worker (executor.sh). The
204202 // cfworker validator does not use code generation and works in every
205203 // runtime we ship to.
206−const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>
204+const createClient = (): Client =>
207205 new Client(
208206 { name: "executor-mcp", version: "0.1.0" },
209207 {
210208 capabilities: { elicitation: { form: {}, url: {} } },
211209 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
212− ...(versionNegotiation === undefined ? {} : { versionNegotiation }),
213210 },
214211 );
215212
@@ -250,10 +247,9 @@ const connectionFailure = ( · 34 unmodified lines
250247 const connectClient = (input: {
251248 transport: string;
252249 createTransport: () => Parameters<Client["connect"]>[0];
253− versionNegotiation?: { readonly mode: "auto" };
254250 }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
255251 Effect.gen(function* () {
256− const client = createClient(input.versionNegotiation);
252+ const client = createClient();
257253 const transportInstance = input.createTransport();
258254
259255 yield* Effect.tryPromise({
@@ -266,15 +262,6 @@ const connectClient = (input: { · 6 unmodified lines
266262 catch: (cause) =>
267263 connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause),
268264 }).pipe(
269− // The negotiated era ("modern" = 2026-07-28 server/discover, "legacy" =
270− // 2025 initialize) is otherwise invisible: both eras list and call tools
271− // identically, so traces are the one place an integration author can
272− // verify which handshake a connection actually used.
273− Effect.tap(() =>
274− Effect.annotateCurrentSpan({
275− "plugin.mcp.protocol_era": client.getProtocolEra() ?? "unknown",
276− }),
277− ),
278265 Effect.withSpan("plugin.mcp.connection.handshake", {
279266 attributes: { "plugin.mcp.transport": input.transport },
280267 }),
@@ -313,12 +300,6 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 32 unmodified lines
313300
314301 return yield* connectClient({
315302 transport: "stdio",
316− // Opt-in per integration (default legacy) — see
317− // `McpStdioVersionNegotiation` for why stdio does not follow the
318− // remote transport's unconditional auto.
319− ...(input.versionNegotiation === "auto"
320− ? { versionNegotiation: { mode: "auto" as const } }
321− : {}),
322303 createTransport: () =>
323304 createStdioTransport({
324305 command,
@@ -338,13 +319,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 13 unmodified lines
338319
339320 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
340321
341− // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable
342− // HTTP. SSE is a legacy-only transport; stdio negotiates per the
343− // integration's `versionNegotiation` (default legacy — see the stdio
344− // branch above).
345322 const connectStreamableHttp = connectClient({
346323 transport: "streamable-http",
347− versionNegotiation: { mode: "auto" },
348324 createTransport: () =>
349325 new StreamableHTTPClientTransport(endpoint, {
350326 requestInit,
packages/plugins/mcp/src/sdk/elicitation.test.ts+3−3
@@ -1,7 +1,7 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Effect, Predicate, Schema, Semaphore } from "effect";
3−import type { JsonSchemaType } from "@modelcontextprotocol/client";
4−import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
3+import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
4+import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";
55
66 import {
77 AuthTemplateSlug,
@@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { · 218 unmodified lines
226226 ]),
227227 );
228228 expect(schema?.outputTypeScript).toContain('type: "text"');
229− expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;");
229+ expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }");
230230
231231 const result = yield* executor.execute(
232232 simpleEcho.address,
packages/plugins/mcp/src/sdk/http-status.test.ts+3−17
@@ -1,5 +1,4 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
32
43 // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
54 import { insufficientScopeFromCause } from "./http-status";
@@ -10,8 +9,7 @@ import { insufficientScopeFromCause } from "./http-status"; · 4 unmodified lines
109 // - with an authProvider (the production OAuth path): the StreamableHTTP
1110 // transport consumes the insufficient_scope challenge itself, retries
1211 // with the broader scope, and only when THAT fails throws the fixed
13−// typed `InsufficientScopeError`, or after retry exhaustion the fixed
14−// `SdkHttpError` step-up message.
12+// "Server returned 403 after trying upscoping" message.
1513 describe("insufficientScopeFromCause", () => {
1614 it("detects the OAuth error body embedded in a transport message", () => {
1715 expect(
@@ -33,21 +31,9 @@ describe("insufficientScopeFromCause", () => { · 15 unmodified lines
3331 ).toBe(true);
3432 });
3533
36− it("detects the SDK's typed insufficient-scope failure", () => {
34+ it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {
3735 expect(
38− insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })),
39− ).toBe(true);
40− });
41−
42− it("detects the SDK's exhausted step-up failure (the authProvider path)", () => {
43− expect(
44− insufficientScopeFromCause(
45− new SdkHttpError(
46− SdkErrorCode.ClientHttpForbidden,
47− "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)",
48− { status: 403 },
49− ),
50− ),
36+ insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")),
5137 ).toBe(true);
5238 });
5339
packages/plugins/mcp/src/sdk/http-status.ts+20−25
@@ -1,8 +1,7 @@
11 // ---------------------------------------------------------------------------
22 // Extract the HTTP status from an MCP SDK transport error. The SDK surfaces
3−// transport failures two ways: an `SdkHttpError` carrying a numeric `status`,
4−// and an `SseError` carrying a numeric `code`. The SSE transport also retains
5−// its historic POST-failure message for errors created below EventSource.
3+// transport failures two ways: a `StreamableHTTPError` subclass carrying a
4+// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`.
65 // Shared by the invoke path (classifies tool-call failures) and the connect
76 // path (so a 401/403 during the handshake reaches the liveness health check).
87 // ---------------------------------------------------------------------------
@@ -10,13 +9,13 @@ · 1 unmodified line
109 import { Option, Schema } from "effect";
1110
1211 import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
13−import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client";
12+import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1413
1514 const SsePostErrorCause = Schema.Struct({ message: Schema.String });
1615 const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
1716
18−// V2 still constructs this exact message in SSEClientTransport._send. A format
19−// drift just yields undefined (generic error, no crash).
17+// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify
18+// on SDK bumps. A format drift just yields undefined (generic error, no crash).
2019 const statusFromSsePostError = (cause: unknown): number | undefined =>
2120 Option.match(decodeSsePostErrorCause(cause), {
2221 onNone: () => undefined,
@@ -27,36 +26,32 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 4 unmodified lines
2726 },
2827 });
2928
30−const statusFromTypedTransportError = (cause: unknown): number | undefined => {
31− if (SdkHttpError.isInstance(cause)) return cause.status;
32− if (SseError.isInstance(cause)) {
33− const code = cause.code;
34− return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
35− }
36− return undefined;
29+const statusFromStreamableHttpError = (cause: unknown): number | undefined => {
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
31+ if (!(cause instanceof StreamableHTTPError)) return undefined;
32+ const code = cause.code;
33+ return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
3734 };
3835
3936 export const httpStatusFromCause = (cause: unknown): number | undefined =>
40− statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause);
37+ statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
4138
4239 // The SDK embeds the upstream response text in the transport error message
4340 // ("Error POSTing to endpoint: <body>"), which is the only place a 403's body
4441 // survives for connections without an authProvider. For OAuth connections the
45−// StreamableHTTP transport consumes the insufficient_scope challenge itself.
46−// V2 throws `InsufficientScopeError` when configured not to reauthorize; after
47−// exhausting its step-up retries it throws `SdkHttpError` with the exact fixed
48−// message matched below (verified against the installed v2 transport source).
49−// Both paths mean the same thing: the grant does not cover the operation, and
50−// re-running the identical flow cannot help. Strict matching
42+// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF:
43+// it re-runs auth requesting the broader scope, and only when that upscoped
44+// retry still 403s does it throw — with the fixed message matched below
45+// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on
46+// SDK bumps). Both paths mean the same thing: the grant does not cover the
47+// operation, and re-running the identical flow cannot help. Strict matching
5148 // (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.
53−const SDK_STEP_UP_EXHAUSTED_RE =
54− /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;
49+// exact upscoping message) — a miss stays on the generic auth path.
50+const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/;
5551
5652 export const insufficientScopeFromCause = (cause: unknown): boolean =>
57− InsufficientScopeError.isInstance(cause) ||
5853 Option.match(decodeSsePostErrorCause(cause), {
5954 onNone: () => false,
6055 onSome: ({ message }) =>
61− insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message),
56+ insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message),
6257 });
packages/plugins/mcp/src/sdk/invoke.test.ts+5−10
@@ -2,12 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
22 import { Effect, Predicate } from "effect";
33 import { HttpServerResponse } from "effect/unstable/http";
44
5−import {
6− ProtocolError,
7− SdkErrorCode,
8− SdkHttpError,
9− type OAuthClientProvider,
10−} from "@modelcontextprotocol/client";
5+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
6+import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7+import { McpError } from "@modelcontextprotocol/sdk/types.js";
118 import { ElicitationResponse } from "@executor-js/sdk";
129 import { serveTestHttpApp } from "@executor-js/sdk/testing";
1310
@@ -111,16 +108,14 @@ const invocationRejectionCases = [ · 97 unmodified lines
111108 name: "wraps callTool rejection with a stable message and status",
112109 toolId: "blocked",
113110 transport: "streamable-http",
114− cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", {
115− status: 401,
116− }),
111+ cause: new StreamableHTTPError(401, "token=do-not-leak"),
117112 expectedStatus: 401 as number | undefined,
118113 },
119114 {
120115 name: "does not treat MCP protocol error codes as HTTP statuses",
121116 toolId: "protocol_error",
122117 transport: "streamable-http",
123− cause: new ProtocolError(401, "application-level do-not-leak"),
118+ cause: new McpError(401, "application-level do-not-leak"),
124119 expectedStatus: undefined,
125120 },
126121 {
packages/plugins/mcp/src/sdk/invoke.ts+15−24
@@ -8,7 +8,7 @@ · 7 unmodified lines
88 // legitimately retain state in that session. The pool keeps one idle
99 // connection per resolved identity and leases it exclusively per invoke;
1010 // stdio and callers without a pool remain strictly per-call.
11−// 2. Installing a per-invocation `elicitation/create` handler that bridges
11+// 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges
1212 // MCP's elicit capability into the host's elicit function threaded via
1313 // `InvokeToolInput.elicit`.
1414 // 3. Calling `client.callTool({ name, arguments })`.
@@ -16,7 +16,12 @@ · 1 unmodified line
1616
1717 import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
1818
19−import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
19+import {
20+ ElicitRequestSchema,
21+ ErrorCode,
22+ McpError,
23+ ToolListChangedNotificationSchema,
24+} from "@modelcontextprotocol/sdk/types.js";
2025
2126 import {
2227 ElicitationId,
@@ -61,10 +66,9 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines
6166
6267 const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>
6368 // 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 &&
65− (cause.code === ProtocolErrorCode.InvalidParams ||
66− cause.code === ProtocolErrorCode.MethodNotFound) &&
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
69+ cause instanceof McpError &&
70+ (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.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
6872 isUnknownToolMessage(cause.message, toolName);
6973
7074 // ---------------------------------------------------------------------------
@@ -89,17 +93,6 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines
8993 type McpElicitParams = typeof McpElicitParams.Type;
9094
9195 const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams);
92−const 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−);
10396
10497 const toElicitationRequest = (params: McpElicitParams): ElicitationRequest =>
10598 params.mode === "url"
@@ -114,7 +107,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 8 unmodified lines
114107 });
115108
116109 const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => {
117− client.setRequestHandler("elicitation/create", async (request: { params: unknown }) => {
110+ client.setRequestHandler(ElicitRequestSchema, async (request: { params: unknown }) => {
118111 const params = decodeElicitParams(request.params);
119112 const req = toElicitationRequest(params);
120113 // Use runPromiseExit so we can inspect typed failures — `elicit`
@@ -126,9 +119,7 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic · 5 unmodified lines
126119 const response = exit.value;
127120 return {
128121 action: response.action,
129− ...(response.action === "accept" && response.content
130− ? { content: decodeElicitContent(response.content) }
131− : {}),
122+ ...(response.action === "accept" && response.content ? { content: response.content } : {}),
132123 };
133124 }
134125 const failure = exit.cause.reasons.find(Cause.isFailReason);
@@ -158,7 +149,7 @@ const installToolListChangedHandler = ( · 23 unmodified lines
158149 onToolListChanged: (() => void) | undefined,
159150 ): void => {
160151 if (!onToolListChanged) return;
161− client.setNotificationHandler("notifications/tools/list_changed", () => {
152+ client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
162153 onToolListChanged();
163154 });
164155 };
@@ -198,8 +189,8 @@ const useConnection = ( · 33 unmodified lines
198189 });
199190 }
200191 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 shapes
202− const protocolFailure = cause instanceof ProtocolError;
192+ // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its McpError subclass; transport failures use other error shapes
193+ const protocolFailure = cause instanceof McpError;
203194 return new McpInvocationError({
204195 toolName,
205196 message: `MCP tool call failed for ${toolName}`,
packages/plugins/mcp/src/sdk/plugin.ts+5−15
@@ -1,8 +1,9 @@
11 import { Effect, Layer, Option, Result, Schema } from "effect";
22 import type { HttpClient } from "effect/unstable/http";
33
4−import type { OAuthClientProvider } from "@modelcontextprotocol/client";
5−import { CallToolResultSchema } from "@modelcontextprotocol/core";
4+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
5+import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
6+import * as z from "zod/v4";
67
78 import {
89 authToolFailure,
@@ -60,7 +61,6 @@ import { · 51 unmodified lines
6061 expandMcpAuthMethodInputs,
6162 mcpAuthMethodFromShorthand,
6263 normalizeMcpAuthMethods,
63− McpStdioVersionNegotiation,
6464 parseMcpIntegrationConfig,
6565 type McpIntegrationConfig as McpIntegrationConfigType,
6666 type McpStdioEnvMethod,
@@ -207,11 +207,6 @@ const McpStdioServerInputSchema = Schema.Struct({ · 140 unmodified lines
207207 * instead and leaves the values to the connect step. */
208208 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
209209 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),
215210 slug: Schema.optional(Schema.String),
216211 });
217212
@@ -375,7 +370,6 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 157 unmodified lines
375370 command: input.command,
376371 args: input.args ? [...input.args] : undefined,
377372 cwd: input.cwd,
378− versionNegotiation: input.versionNegotiation,
379373 authenticationTemplate:
380374 vars.length > 0
381375 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }]
@@ -398,7 +392,7 @@ type JsonSchemaObject = Record<string, unknown> & { · 16 unmodified lines
398392 readonly properties?: Record<string, unknown>;
399393 };
400394
401−const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema();
395+const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject;
402396
403397 const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {
404398 const defaultStructuredContentSchema =
@@ -499,9 +493,7 @@ export const userFacingProbeMessage = ( · 94 unmodified lines
499493 // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the
500494 // transport sends it as a Bearer header. Refresh is core's responsibility
501495 // (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 credentials
503−// with the authorization-server issuer and offers scoped invalidation; this
504−// single-token boundary intentionally persists neither.
496+// a new flow and fails loudly if the SDK tries to.
505497 // ---------------------------------------------------------------------------
506498
507499 const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({
@@ -594,7 +586,6 @@ const buildConnectorInput = ( · 86 unmodified lines
594586 args: config.args,
595587 env: Object.keys(env).length > 0 ? env : undefined,
596588 cwd: config.cwd,
597− versionNegotiation: config.versionNegotiation,
598589 } satisfies McpStdioIntegrationConfig);
599590 }
600591
@@ -1053,7 +1044,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 452 unmodified lines
10531044 command: config.command,
10541045 args: config.args,
10551046 cwd: config.cwd,
1056− versionNegotiation: config.versionNegotiation,
10571047 authenticationTemplate: hasEnv
10581048 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }]
10591049 : [{ slug: "none", kind: "none" }],
packages/plugins/mcp/src/sdk/probe-shape.test.ts+2−90
@@ -1,12 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Layer, Ref } from "effect";
3−import {
4− HttpClient,
5− HttpClientError,
6− HttpClientRequest,
7− HttpClientResponse,
8− HttpServerResponse,
9−} from "effect/unstable/http";
2+import { Effect, Ref } from "effect";
3+import { HttpServerResponse } from "effect/unstable/http";
104 import { serveTestHttpApp } from "@executor-js/sdk/testing";
115
126 import { probeMcpEndpointShape } from "./probe-shape";
@@ -326,88 +320,6 @@ describe("probeMcpEndpointShape", () => { · 313 unmodified lines
326320 ),
327321 );
328322
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−
411323 it.effect("rejects 2xx with HTML body as wrong-shape", () =>
412324 withServer(
413325 () =>
packages/plugins/mcp/src/sdk/probe-shape.ts+10−51
@@ -13,7 +13,7 @@ · 12 unmodified lines
1313 // and (b) plenty of real MCP servers authenticate with static API
1414 // keys and publish no OAuth metadata at all (e.g. cubic.dev).
1515 //
16−// The primary probe issues an unauth JSON-RPC `initialize` POST and accepts
16+// The probe issues an unauth JSON-RPC `initialize` POST and accepts
1717 // only the wire shapes a real MCP server can return:
1818 //
1919 // - 2xx with `Content-Type: text/event-stream` — streamable HTTP
@@ -30,14 +30,8 @@ · 10 unmodified lines
3030 // only accepts 2xx with `text/event-stream` or the same 401+Bearer
3131 // shape.
3232 //
33−// If initialize ultimately has the wrong shape, a second JSON-RPC POST probes
34−// `server/discover` using the 2026-07-28 envelope and protocol header. This
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.
33+// One `fetch` (occasionally two), no MCP-SDK session state, no OAuth
34+// round-trip, no DCR — every non-MCP endpoint exits here.
4135 // ---------------------------------------------------------------------------
4236
4337 import { Data, Duration, Effect, Layer, Option, Schema } from "effect";
@@ -54,21 +48,12 @@ const INITIALIZE_BODY = JSON.stringify({ · 10 unmodified lines
5448 id: 1,
5549 method: "initialize",
5650 params: {
57− protocolVersion: "2025-11-25",
51+ protocolVersion: "2025-06-18",
5852 capabilities: {},
5953 clientInfo: { name: "executor-probe", version: "0" },
6054 },
6155 });
6256
63−const 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−
7257 /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s
7358 * `Response.headers` already lower-cases, but we normalise explicitly
7459 * to stay robust against test mocks that construct `Headers` loosely. */
@@ -400,9 +385,10 @@ export const probeMcpEndpointShape = ( · 325 unmodified lines
400385 .execute(postRequest)
401386 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
402387
403− let initializeResult = yield* classify(postResponse, "POST");
388+ const postResult = yield* classify(postResponse, "POST");
389+ if (postResult) return postResult;
404390
405− if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) {
391+ if ([404, 405, 406, 415].includes(postResponse.status)) {
406392 let getRequest = HttpClientRequest.get(url.toString()).pipe(
407393 HttpClientRequest.setHeader("accept", "text/event-stream"),
408394 );
@@ -412,42 +398,15 @@ export const probeMcpEndpointShape = ( · 3 unmodified lines
412398 const getResponse = yield* client
413399 .execute(getRequest)
414400 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
415− initializeResult = yield* classify(getResponse, "GET");
401+ const getResult = yield* classify(getResponse, "GET");
402+ if (getResult) return getResult;
416403 }
417404
418− initializeResult ??= {
405+ return {
419406 kind: "not-mcp",
420407 category: "wrong-shape",
421408 reason: `unexpected status ${postResponse.status} for initialize`,
422409 } 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;
451410 }).pipe(
452411 Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),
453412 Effect.mapError(
packages/plugins/mcp/src/sdk/stdio-connector.ts+3−4
@@ -3,9 +3,8 @@ · 2 unmodified lines
33 // ---------------------------------------------------------------------------
44 //
55 // Kept in its own module so `connection.ts` never imports it eagerly at
6−// module load. The v2 `@modelcontextprotocol/client/stdio` entry still eagerly
7−// evaluates Node-only process/stream imports and `cross-spawn` (which loads
8−// `node:child_process`); under `@cloudflare/vitest-pool-workers`
6+// module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in
7+// `node:child_process` at evaluation time; under `@cloudflare/vitest-pool-workers`
98 // that crashes workerd at module instantiation with SIGSEGV (prod bundles
109 // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not).
1110 //
@@ -14,7 +13,7 @@ · 2 unmodified lines
1413 // the import and therefore never touch `node:child_process`.
1514 // ---------------------------------------------------------------------------
1615
17−import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
16+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
1817
1918 export type StdioTransportConfig = {
2019 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.
6−import { McpServer } from "@modelcontextprotocol/server";
7−import { serveStdio } from "@modelcontextprotocol/server/stdio";
8−import * as z from "zod/v4";
9−
10−serveStdio(
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 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Predicate } from "effect";
3−import { fileURLToPath } from "node:url";
4−
5−import { createMcpConnector, type StdioConnectorInput } from "./connection";
6−
7−const fixture = fileURLToPath(new URL("./stdio-negotiation-test-server.ts", import.meta.url));
8−
9−const 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−
19−const withConnection = (input: StdioConnectorInput) =>
20− Effect.acquireRelease(createMcpConnector(input).pipe(Effect.orDie), (connection) =>
21− Effect.promise(connection.close),
22− );
23−
24−describe("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 @@
11 import { expect, layer } from "@effect/vitest";
22 import { Effect } from "effect";
3−import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
45 import { OAuthTestServer } from "@executor-js/sdk/testing";
56
67 import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing";
@@ -15,10 +16,7 @@ const createGreetingMcpServer = () => · 8 unmodified lines
1516 });
1617
1718 const makeClient = (endpoint: string, accessToken: string) => {
18− const client = new Client(
19− { name: "executor-test-client", version: "1.0.0" },
20− { versionNegotiation: { mode: "auto" } },
21− );
19+ const client = new Client({ name: "executor-test-client", version: "1.0.0" });
2220 const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
2321 requestInit: {
2422 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
3131 export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]);
3232 export type McpTransport = typeof McpTransport.Type;
3333
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. */
45−export const McpStdioVersionNegotiation = Schema.Literals(["legacy", "auto"]);
46−export type McpStdioVersionNegotiation = typeof McpStdioVersionNegotiation.Type;
47−
4834 // ---------------------------------------------------------------------------
4935 // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`)
5036 // 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
222208 env: Schema.optional(StringMap),
223209 /** Working directory */
224210 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),
228211 /** Declared auth methods — a single `stdio_env` method naming the secret env
229212 * vars, or `none`. A connection's `template` picks one by slug, exactly as
230213 * 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 @@
1−import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect";
1+import { Context, Data, Effect, Layer, Ref, Scope } from "effect";
22 import * as http from "node:http";
33 // Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients.
44 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6−import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
76 import { OAuthTestServer } from "@executor-js/sdk/testing";
87 import z from "zod";
98
@@ -59,22 +58,6 @@ const writeText = (response: http.ServerResponse, status: number, body: string) · 49 unmodified lines
5958 response.end(body);
6059 };
6160
62−const 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−
76−const decodeJsonBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
77−
7861 const isMcpPath = (url: string, path: string): boolean => {
7962 const parsed = new URL(url, "http://executor.test");
8063 return parsed.pathname === path;
@@ -175,24 +158,6 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 94 unmodified lines
175158 return;
176159 }
177160
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−
196161 const transport = new StreamableHTTPServerTransport({
197162 sessionIdGenerator: () => crypto.randomUUID(),
198163 onsessioninitialized: (sid) => {
@@ -208,7 +173,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 9 unmodified lines
208173 catch: (cause) => new McpTestServerError({ cause }),
209174 });
210175 yield* Effect.tryPromise({
211− try: () => transport.handleRequest(request, response, parsedBody),
176+ try: () => transport.handleRequest(request, response),
212177 catch: (cause) => new McpTestServerError({ cause }),
213178 });
214179 }).pipe(
plans/kill-plugin-system.md+515−0
@@ -0,0 +1,515 @@
1+# Kill the plugin system — in-place migration plan
2+
3+Decision (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,
6+each keeping format/lint/typecheck/test/e2e green. Priority order: **data
7+models first**, then the plugin kill, then the concept-package (apps) model,
8+then the minimal SDK / platform split.
9+
10+RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now
11+lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.**
12+Rationale: the new model's tables are greenfield and additive — landing it
13+first means the legacy apps tables never need an in-place migration; the whole
14+model swaps underneath while openapi/mcp/graphql keep running as plugins.
15+Structurally sound because (a) the kernel + MCP host are already plugin-free,
16+so the new model's runtime never touched the registry, and (b) the apps plugin
17+already exercises every extension point, so the new model ships as the apps
18+plugin's new internals wearing the same costume. When 2.3 later converts the
19+four kinds, apps converts as a stable, proven model instead of mid-rewrite.
20+Phase 0 and 2.1 remain freely parallel — they don't touch apps.
21+
22+Hard line: side-by-side means new-apps-model alongside the _protocol plugins_,
23+never two app models with dual read paths inside the apps kind. Existing app
24+sources get a one-off migration to published-source form (enumerated
25+invariants, explicit approval, no permanent compatibility path). Accepted
26+cost: the new model's HTTP surface temporarily wears plugin-route ceremony
27+(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not
28+model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes
29+public early; its boundaries take Standard Schema (StandardSchemaV1 &
30+StandardJSONSchemaV1), 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
36+lines, ~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;
70+its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it
71+failed, 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+
84+1. **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.
90+2. **`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.
96+3. **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.
103+4. **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.
106+5. **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.
109+6. **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.
112+7. **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.
115+8. **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+
135+Design 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+
150+PRs:
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+
177+Toolkits data does NOT move in this phase (it moves with its reader in 2.4 —
178+moving it earlier means either rewriting the plugin storage layer twice or a
179+forbidden dual read path).
180+
181+### Framing: product view vs platform view (Rhys, 2026-07-27)
182+
183+The 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+
194+Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids —
195+their users authenticate through our WorkOS, so `subject` is _our_ accountId
196+and the customer correlates on their side. The opaque-owner-ids-in-the-
197+customer's-id-space story fully applies only to embedded/API consumers; for
198+cloud, the platform view (+ `external_id` on the subject table) is what makes
199+the correlation tractable.
200+
201+### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone)
202+
203+The owner.com ask (admin inspects users' connections; API key that can do the
204+same; available-vs-connected icon grid; direct connect links) needs only
205+1.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the
206+plugin 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,
261+inside the apps plugin's existing costume; Phase 2 kills the registry
262+afterward, with apps converting in 2.3 as an already-stable model.)
263+
264+Port the rework's app model in place as the `apps` source kind's new model:
265+published source, manifest derived by execution, content-hashed immutable
266+versions, rollback = pointer move. New tables are additive greenfield (same
267+pattern as 1.1: coreTables + cloud mirror + migration, no readers first).
268+Existing app sources migrate one-off to published-source form — no dual model
269+within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an
270+optional on-ramp (spec → generated app source); GraphQL generator later.
271+Long-term option (explicitly out of scope): protocol kinds themselves become
272+generators 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
277+collections, sync state machine, publish/discover/descriptor, routes,
278+console client). ~2.6k is model-agnostic and survives verbatim: all three
279+sandbox executors, the bundler stack, bindings/bridge, authoring +
280+standard-schema, conformance. ~1.9k (hand-rolled git client + source
281+fetchers) hangs on the publish-input decision below. Storage is entirely
282+generic `plugin_storage` rows + content-addressed blobs — zero apps-specific
283+tables or migrations exist, so new tables are pure greenfield. Cloud prod is
284+git-source-only; **source text is never persisted today (only compiled
285+bundles)**, so the one-off migration re-fetches source from the recorded git
286+URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses
287+`tools.<app>.org.published.<name>` + the `<fileSlug>__<key>` naming rule,
288+app-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
291+cloud+selfhost console loop), local directory-picker, packed-CLI, desktop
292+e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never
293+GC'd and org purge misses them; cloud silently falls back to in-process
294+executor without the worker loader; host-cloudflare doesn't register apps.
295+
296+**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK,
297+publish-by-execution, projection, generator verification, storage port,
298+content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP
299+0.6k — computed group with real streamable-HTTP client; json-schema engine;
300+byte-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
303+typed first-class alternative — matches our rule. Effect beta.93→beta.59
304+delta is small (ConstraintDecoder → one-line fix; hand-roll
305+StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is:
306+rework `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
308+model-owned logic: computed-surface cache + requirement resolution +
309+runTool). Known rework gaps to fix at port time, not inherit: derive-time
310+execution is unsandboxed in-process (`code-executor.ts:23` records it) — our
311+port MUST run publish-time evaluation in the existing sandbox `collect` path
312+from day one; fan-in computed groups unsupported; OAuth discovery
313+unsupported.
314+
315+**Semantic deltas — decided by Rhys before 3.1 specs:**
316+
317+1. **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.
327+2. **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.
332+3. **App identity** moves from caller-supplied slug to the `defineApp`
333+ export inside the source (rework shape).
334+4. **Versioning**: content hash of source IS the version identity; the
335+ caller-supplied `sourceRef` + descriptor v6 die with the old model.
336+5. **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+
366+The rework's SDK has exactly one tenancy concept: `SpaceId`
367+(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as
368+the 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,
397+before 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
400+is de facto the space — cloud writes the WorkOS org id there, self-host/local
401+write 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
403+first space is grandfathered with `spaceId = <historical tenant value>` (the
404+org id); NEW spaces mint fresh ids written into the same column. The org→
405+space mirror table records which space ids belong to which org (and carries
406+the billing join — Autumn keys on workosOrgId, never a space). Existing rows
407+never move; multi-space needs no schema change on scoped tables. "Space" is
408+contract 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+
445+Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK
446+from birth; S3/S4 can proceed in parallel with the apps port.
447+
448+Interaction with shipped Phase 1 work: the subject table and platform view
449+are edge/deployment concepts (who exists in this identity world, what may
450+an 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+
455+Direction: the SDK should be agnostic to the consumer's data-model shape, the
456+way Composio's is — consumers pass an opaque user/owner id string and hang
457+whatever hierarchy they have (user, team, workspace) off it; the SDK never
458+models their org chart. The rework already designed this (`OwnerRef` opaque
459+brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt
460+its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept
461+(which Executor-cloud org owns the rows) but must not leak into surfaces
462+consumers touch.
463+
464+Rules, 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+
487+New consumer boundary modeled on the rework: `createExecutor` /
488+`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` +
489+connections + the new users/admin surface; policies/toolkits become optional
490+layers consumers opt into, not baked opinions (Composio-style minimalism).
491+
492+Authorization end-state (Rhys, 2026-07-27): the engine enforces scope
493+mechanically but never decides policy — the host computes, per request, the
494+set of owners the request may read (the rework's `Reach = { owners }`), and
495+the 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
497+set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so
498+cloud needn't enumerate), and in-between cases (team views, support sessions,
499+subset-restricted org keys) are just different sets. Writes stay hard-pinned
500+to a single owner; only read reach generalizes. Trust model becomes a
501+per-deployment choice: a trusted backend with a server key gets tenant-wide
502+reach and layers its own authorization (Composio-style); an end-user session
503+gets a reach of one. The enum-to-set swap is contained: the policy context is
504+constructed in exactly two places.
505+Start the platform/product package separation _during_ 2.3–2.6 (each PR moves
506+one thing to its final home) rather than as a big bang; the SDK boundary
507+itself 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.