* 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.
Unified · Split
| @@ -728,7 +728,7 @@ · 727 unmodified lines | ||
| 728 | 728 | "@executor-js/react": "workspace:*", |
| 729 | 729 | "@executor-js/runtime-quickjs": "workspace:*", |
| 730 | 730 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 731 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 731 | + "@modelcontextprotocol/sdk": "^1.12.1", | |
| 732 | 732 | "@tanstack/react-query": "^5.99.0", |
| 733 | 733 | "effect": "catalog:", |
| 734 | 734 | "esbuild": "^0.27.7", |
| @@ -1001,8 +1001,6 @@ · 266 unmodified lines | ||
| 1001 | 1001 | "@effect/platform-node": "catalog:", |
| 1002 | 1002 | "@executor-js/config": "workspace:*", |
| 1003 | 1003 | "@executor-js/sdk": "workspace:*", |
| 1004 | − "@modelcontextprotocol/client": "2.0.0", | |
| 1005 | − "@modelcontextprotocol/core": "2.0.0", | |
| 1006 | 1004 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 1007 | 1005 | "zod": "4.3.6", |
| 1008 | 1006 | }, |
| @@ -1011,7 +1009,6 @@ · 2 unmodified lines | ||
| 1011 | 1009 | "@effect/vitest": "catalog:", |
| 1012 | 1010 | "@executor-js/api": "workspace:*", |
| 1013 | 1011 | "@executor-js/react": "workspace:*", |
| 1014 | − "@modelcontextprotocol/server": "2.0.0", | |
| 1015 | 1012 | "@types/node": "catalog:", |
| 1016 | 1013 | "@types/react": "catalog:", |
| 1017 | 1014 | "bun-types": "catalog:", |
| @@ -146,36 +146,6 @@ scenario( · 145 unmodified lines | ||
| 146 | 146 | declTools.map((t) => t.name), |
| 147 | 147 | "connecting with the secret discovers the env-gated tool", |
| 148 | 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 | }), |
| @@ -69,7 +69,7 @@ · 68 unmodified lines | ||
| 69 | 69 | "@executor-js/react": "workspace:*", |
| 70 | 70 | "@executor-js/runtime-quickjs": "workspace:*", |
| 71 | 71 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 72 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 72 | + "@modelcontextprotocol/sdk": "^1.12.1", | |
| 73 | 73 | "@tanstack/react-query": "^5.99.0", |
| 74 | 74 | "effect": "catalog:", |
| 75 | 75 | "esbuild": "^0.27.7", |
| @@ -65,8 +65,6 @@ · 64 unmodified lines | ||
| 65 | 65 | "@effect/platform-node": "catalog:", |
| 66 | 66 | "@executor-js/config": "workspace:*", |
| 67 | 67 | "@executor-js/sdk": "workspace:*", |
| 68 | − "@modelcontextprotocol/client": "2.0.0", | |
| 69 | − "@modelcontextprotocol/core": "2.0.0", | |
| 70 | 68 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 71 | 69 | "zod": "4.3.6" |
| 72 | 70 | }, |
| @@ -75,7 +73,6 @@ · 2 unmodified lines | ||
| 75 | 73 | "@effect/vitest": "catalog:", |
| 76 | 74 | "@executor-js/api": "workspace:*", |
| 77 | 75 | "@executor-js/react": "workspace:*", |
| 78 | − "@modelcontextprotocol/server": "2.0.0", | |
| 79 | 76 | "@types/node": "catalog:", |
| 80 | 77 | "@types/react": "catalog:", |
| 81 | 78 | "bun-types": "catalog:", |
| @@ -57,10 +57,6 @@ const AddStdioServerPayload = Schema.Struct({ · 56 unmodified lines | ||
| 57 | 57 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ |
| 58 | 58 | env: Schema.optional(StringMap), |
| 59 | 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 | 60 | slug: Schema.optional(Schema.String), |
| 65 | 61 | }); |
| 66 | 62 | |
| @@ -39,7 +39,6 @@ const toServerInput = ( · 38 unmodified lines | ||
| 39 | 39 | envVars?: readonly string[]; |
| 40 | 40 | env?: Record<string, string>; |
| 41 | 41 | cwd?: string; |
| 42 | − versionNegotiation?: "legacy" | "auto"; | |
| 43 | 42 | slug?: string; |
| 44 | 43 | }; |
| 45 | 44 | return { |
| @@ -51,7 +50,6 @@ const toServerInput = ( · 5 unmodified lines | ||
| 51 | 50 | envVars: p.envVars ? [...p.envVars] : undefined, |
| 52 | 51 | env: p.env, |
| 53 | 52 | cwd: p.cwd, |
| 54 | − versionNegotiation: p.versionNegotiation, | |
| 55 | 53 | slug: p.slug, |
| 56 | 54 | }; |
| 57 | 55 | } |
| @@ -3,10 +3,6 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import type { McpConnection, McpConnector } from "./connection"; |
| 4 | 4 | import 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 | − | |
| 10 | 6 | const IDLE_TTL_MS = 5 * 60 * 1_000; |
| 11 | 7 | |
| 12 | 8 | type IdleConnection = { |
| @@ -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"; | |
| 9 | 7 | import { Effect, Layer, Predicate, Stream } from "effect"; |
| 10 | 8 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 11 | 9 | |
| 12 | 10 | // 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 | |
| 16 | 14 | // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below; |
| 17 | 15 | // prod bundles that DO use stdio load it via a dynamic import inside the |
| 18 | 16 | // stdio branch of `createMcpConnector`. |
| @@ -203,13 +201,12 @@ const fetchFromHttpClientLayer = ( · 184 unmodified lines | ||
| 203 | 201 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The |
| 204 | 202 | // cfworker validator does not use code generation and works in every |
| 205 | 203 | // runtime we ship to. |
| 206 | −const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client => | |
| 204 | +const createClient = (): Client => | |
| 207 | 205 | new Client( |
| 208 | 206 | { name: "executor-mcp", version: "0.1.0" }, |
| 209 | 207 | { |
| 210 | 208 | capabilities: { elicitation: { form: {}, url: {} } }, |
| 211 | 209 | jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), |
| 212 | − ...(versionNegotiation === undefined ? {} : { versionNegotiation }), | |
| 213 | 210 | }, |
| 214 | 211 | ); |
| 215 | 212 | |
| @@ -250,10 +247,9 @@ const connectionFailure = ( · 34 unmodified lines | ||
| 250 | 247 | const connectClient = (input: { |
| 251 | 248 | transport: string; |
| 252 | 249 | createTransport: () => Parameters<Client["connect"]>[0]; |
| 253 | − versionNegotiation?: { readonly mode: "auto" }; | |
| 254 | 250 | }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> => |
| 255 | 251 | Effect.gen(function* () { |
| 256 | − const client = createClient(input.versionNegotiation); | |
| 252 | + const client = createClient(); | |
| 257 | 253 | const transportInstance = input.createTransport(); |
| 258 | 254 | |
| 259 | 255 | yield* Effect.tryPromise({ |
| @@ -266,15 +262,6 @@ const connectClient = (input: { · 6 unmodified lines | ||
| 266 | 262 | catch: (cause) => |
| 267 | 263 | connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), |
| 268 | 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 | 265 | Effect.withSpan("plugin.mcp.connection.handshake", { |
| 279 | 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 | 301 | return yield* connectClient({ |
| 315 | 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 | 303 | createTransport: () => |
| 323 | 304 | createStdioTransport({ |
| 324 | 305 | command, |
| @@ -338,13 +319,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 13 unmodified lines | ||
| 338 | 319 | |
| 339 | 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 | 322 | const connectStreamableHttp = connectClient({ |
| 346 | 323 | transport: "streamable-http", |
| 347 | − versionNegotiation: { mode: "auto" }, | |
| 348 | 324 | createTransport: () => |
| 349 | 325 | new StreamableHTTPClientTransport(endpoint, { |
| 350 | 326 | requestInit, |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | 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"; | |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | 7 | AuthTemplateSlug, |
| @@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { · 218 unmodified lines | ||
| 226 | 226 | ]), |
| 227 | 227 | ); |
| 228 | 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 | 231 | const result = yield* executor.execute( |
| 232 | 232 | simpleEcho.address, |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; | |
| 3 | 2 | |
| 4 | 3 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors |
| 5 | 4 | import { insufficientScopeFromCause } from "./http-status"; |
| @@ -10,8 +9,7 @@ import { insufficientScopeFromCause } from "./http-status"; · 4 unmodified lines | ||
| 10 | 9 | // - with an authProvider (the production OAuth path): the StreamableHTTP |
| 11 | 10 | // transport consumes the insufficient_scope challenge itself, retries |
| 12 | 11 | // 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. | |
| 15 | 13 | describe("insufficientScopeFromCause", () => { |
| 16 | 14 | it("detects the OAuth error body embedded in a transport message", () => { |
| 17 | 15 | expect( |
| @@ -33,21 +31,9 @@ describe("insufficientScopeFromCause", () => { · 15 unmodified lines | ||
| 33 | 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 | 35 | 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")), | |
| 51 | 37 | ).toBe(true); |
| 52 | 38 | }); |
| 53 | 39 | |
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | 2 | // 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)`. | |
| 6 | 5 | // Shared by the invoke path (classifies tool-call failures) and the connect |
| 7 | 6 | // path (so a 401/403 during the handshake reaches the liveness health check). |
| 8 | 7 | // --------------------------------------------------------------------------- |
| @@ -10,13 +9,13 @@ · 1 unmodified line | ||
| 10 | 9 | import { Option, Schema } from "effect"; |
| 11 | 10 | |
| 12 | 11 | import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; |
| 13 | −import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client"; | |
| 12 | +import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 14 | 13 | |
| 15 | 14 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); |
| 16 | 15 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); |
| 17 | 16 | |
| 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). | |
| 20 | 19 | const statusFromSsePostError = (cause: unknown): number | undefined => |
| 21 | 20 | Option.match(decodeSsePostErrorCause(cause), { |
| 22 | 21 | onNone: () => undefined, |
| @@ -27,36 +26,32 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 4 unmodified lines | ||
| 27 | 26 | }, |
| 28 | 27 | }); |
| 29 | 28 | |
| 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; | |
| 37 | 34 | }; |
| 38 | 35 | |
| 39 | 36 | export const httpStatusFromCause = (cause: unknown): number | undefined => |
| 40 | − statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause); | |
| 37 | + statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause); | |
| 41 | 38 | |
| 42 | 39 | // The SDK embeds the upstream response text in the transport error message |
| 43 | 40 | // ("Error POSTing to endpoint: <body>"), which is the only place a 403's body |
| 44 | 41 | // 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 | |
| 51 | 48 | // (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/; | |
| 55 | 51 | |
| 56 | 52 | export const insufficientScopeFromCause = (cause: unknown): boolean => |
| 57 | − InsufficientScopeError.isInstance(cause) || | |
| 58 | 53 | Option.match(decodeSsePostErrorCause(cause), { |
| 59 | 54 | onNone: () => false, |
| 60 | 55 | onSome: ({ message }) => |
| 61 | − insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message), | |
| 56 | + insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message), | |
| 62 | 57 | }); |
| @@ -2,12 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import { Effect, Predicate } from "effect"; |
| 3 | 3 | import { HttpServerResponse } from "effect/unstable/http"; |
| 4 | 4 | |
| 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"; | |
| 11 | 8 | import { ElicitationResponse } from "@executor-js/sdk"; |
| 12 | 9 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 13 | 10 | |
| @@ -111,16 +108,14 @@ const invocationRejectionCases = [ · 97 unmodified lines | ||
| 111 | 108 | name: "wraps callTool rejection with a stable message and status", |
| 112 | 109 | toolId: "blocked", |
| 113 | 110 | 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"), | |
| 117 | 112 | expectedStatus: 401 as number | undefined, |
| 118 | 113 | }, |
| 119 | 114 | { |
| 120 | 115 | name: "does not treat MCP protocol error codes as HTTP statuses", |
| 121 | 116 | toolId: "protocol_error", |
| 122 | 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 | 119 | expectedStatus: undefined, |
| 125 | 120 | }, |
| 126 | 121 | { |
| @@ -8,7 +8,7 @@ · 7 unmodified lines | ||
| 8 | 8 | // legitimately retain state in that session. The pool keeps one idle |
| 9 | 9 | // connection per resolved identity and leases it exclusively per invoke; |
| 10 | 10 | // 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 | |
| 12 | 12 | // MCP's elicit capability into the host's elicit function threaded via |
| 13 | 13 | // `InvokeToolInput.elicit`. |
| 14 | 14 | // 3. Calling `client.callTool({ name, arguments })`. |
| @@ -16,7 +16,12 @@ · 1 unmodified line | ||
| 16 | 16 | |
| 17 | 17 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; |
| 18 | 18 | |
| 19 | −import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; | |
| 19 | +import { | |
| 20 | + ElicitRequestSchema, | |
| 21 | + ErrorCode, | |
| 22 | + McpError, | |
| 23 | + ToolListChangedNotificationSchema, | |
| 24 | +} from "@modelcontextprotocol/sdk/types.js"; | |
| 20 | 25 | |
| 21 | 26 | import { |
| 22 | 27 | ElicitationId, |
| @@ -61,10 +66,9 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines | ||
| 61 | 66 | |
| 62 | 67 | const isUnknownToolCause = (cause: unknown, toolName: string): boolean => |
| 63 | 68 | // 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 | |
| 68 | 72 | isUnknownToolMessage(cause.message, toolName); |
| 69 | 73 | |
| 70 | 74 | // --------------------------------------------------------------------------- |
| @@ -89,17 +93,6 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines | ||
| 89 | 93 | type McpElicitParams = typeof McpElicitParams.Type; |
| 90 | 94 | |
| 91 | 95 | 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 | −); | |
| 103 | 96 | |
| 104 | 97 | const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => |
| 105 | 98 | params.mode === "url" |
| @@ -114,7 +107,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 8 unmodified lines | ||
| 114 | 107 | }); |
| 115 | 108 | |
| 116 | 109 | 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 }) => { | |
| 118 | 111 | const params = decodeElicitParams(request.params); |
| 119 | 112 | const req = toElicitationRequest(params); |
| 120 | 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 | 119 | const response = exit.value; |
| 127 | 120 | return { |
| 128 | 121 | action: response.action, |
| 129 | − ...(response.action === "accept" && response.content | |
| 130 | − ? { content: decodeElicitContent(response.content) } | |
| 131 | − : {}), | |
| 122 | + ...(response.action === "accept" && response.content ? { content: response.content } : {}), | |
| 132 | 123 | }; |
| 133 | 124 | } |
| 134 | 125 | const failure = exit.cause.reasons.find(Cause.isFailReason); |
| @@ -158,7 +149,7 @@ const installToolListChangedHandler = ( · 23 unmodified lines | ||
| 158 | 149 | onToolListChanged: (() => void) | undefined, |
| 159 | 150 | ): void => { |
| 160 | 151 | if (!onToolListChanged) return; |
| 161 | − client.setNotificationHandler("notifications/tools/list_changed", () => { | |
| 152 | + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { | |
| 162 | 153 | onToolListChanged(); |
| 163 | 154 | }); |
| 164 | 155 | }; |
| @@ -198,8 +189,8 @@ const useConnection = ( · 33 unmodified lines | ||
| 198 | 189 | }); |
| 199 | 190 | } |
| 200 | 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 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; | |
| 203 | 194 | return new McpInvocationError({ |
| 204 | 195 | toolName, |
| 205 | 196 | message: `MCP tool call failed for ${toolName}`, |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import { Effect, Layer, Option, Result, Schema } from "effect"; |
| 2 | 2 | import type { HttpClient } from "effect/unstable/http"; |
| 3 | 3 | |
| 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"; | |
| 6 | 7 | |
| 7 | 8 | import { |
| 8 | 9 | authToolFailure, |
| @@ -60,7 +61,6 @@ import { · 51 unmodified lines | ||
| 60 | 61 | expandMcpAuthMethodInputs, |
| 61 | 62 | mcpAuthMethodFromShorthand, |
| 62 | 63 | normalizeMcpAuthMethods, |
| 63 | − McpStdioVersionNegotiation, | |
| 64 | 64 | parseMcpIntegrationConfig, |
| 65 | 65 | type McpIntegrationConfig as McpIntegrationConfigType, |
| 66 | 66 | type McpStdioEnvMethod, |
| @@ -207,11 +207,6 @@ const McpStdioServerInputSchema = Schema.Struct({ · 140 unmodified lines | ||
| 207 | 207 | * instead and leaves the values to the connect step. */ |
| 208 | 208 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 209 | 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 | 210 | slug: Schema.optional(Schema.String), |
| 216 | 211 | }); |
| 217 | 212 | |
| @@ -375,7 +370,6 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 157 unmodified lines | ||
| 375 | 370 | command: input.command, |
| 376 | 371 | args: input.args ? [...input.args] : undefined, |
| 377 | 372 | cwd: input.cwd, |
| 378 | − versionNegotiation: input.versionNegotiation, | |
| 379 | 373 | authenticationTemplate: |
| 380 | 374 | vars.length > 0 |
| 381 | 375 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] |
| @@ -398,7 +392,7 @@ type JsonSchemaObject = Record<string, unknown> & { · 16 unmodified lines | ||
| 398 | 392 | readonly properties?: Record<string, unknown>; |
| 399 | 393 | }; |
| 400 | 394 | |
| 401 | −const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema(); | |
| 395 | +const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; | |
| 402 | 396 | |
| 403 | 397 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { |
| 404 | 398 | const defaultStructuredContentSchema = |
| @@ -499,9 +493,7 @@ export const userFacingProbeMessage = ( · 94 unmodified lines | ||
| 499 | 493 | // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the |
| 500 | 494 | // transport sends it as a Bearer header. Refresh is core's responsibility |
| 501 | 495 | // (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. | |
| 505 | 497 | // --------------------------------------------------------------------------- |
| 506 | 498 | |
| 507 | 499 | const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ |
| @@ -594,7 +586,6 @@ const buildConnectorInput = ( · 86 unmodified lines | ||
| 594 | 586 | args: config.args, |
| 595 | 587 | env: Object.keys(env).length > 0 ? env : undefined, |
| 596 | 588 | cwd: config.cwd, |
| 597 | − versionNegotiation: config.versionNegotiation, | |
| 598 | 589 | } satisfies McpStdioIntegrationConfig); |
| 599 | 590 | } |
| 600 | 591 | |
| @@ -1053,7 +1044,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 452 unmodified lines | ||
| 1053 | 1044 | command: config.command, |
| 1054 | 1045 | args: config.args, |
| 1055 | 1046 | cwd: config.cwd, |
| 1056 | − versionNegotiation: config.versionNegotiation, | |
| 1057 | 1047 | authenticationTemplate: hasEnv |
| 1058 | 1048 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] |
| 1059 | 1049 | : [{ slug: "none", kind: "none" }], |
| @@ -1,12 +1,6 @@ | ||
| 1 | 1 | 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"; | |
| 10 | 4 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 11 | 5 | |
| 12 | 6 | import { 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 | 323 | it.effect("rejects 2xx with HTML body as wrong-shape", () => |
| 412 | 324 | withServer( |
| 413 | 325 | () => |
| @@ -13,7 +13,7 @@ · 12 unmodified lines | ||
| 13 | 13 | // and (b) plenty of real MCP servers authenticate with static API |
| 14 | 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 accepts | |
| 16 | +// The probe issues an unauth JSON-RPC `initialize` POST and accepts | |
| 17 | 17 | // only the wire shapes a real MCP server can return: |
| 18 | 18 | // |
| 19 | 19 | // - 2xx with `Content-Type: text/event-stream` — streamable HTTP |
| @@ -30,14 +30,8 @@ · 10 unmodified lines | ||
| 30 | 30 | // only accepts 2xx with `text/event-stream` or the same 401+Bearer |
| 31 | 31 | // shape. |
| 32 | 32 | // |
| 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. | |
| 41 | 35 | // --------------------------------------------------------------------------- |
| 42 | 36 | |
| 43 | 37 | import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; |
| @@ -54,21 +48,12 @@ const INITIALIZE_BODY = JSON.stringify({ · 10 unmodified lines | ||
| 54 | 48 | id: 1, |
| 55 | 49 | method: "initialize", |
| 56 | 50 | params: { |
| 57 | − protocolVersion: "2025-11-25", | |
| 51 | + protocolVersion: "2025-06-18", | |
| 58 | 52 | capabilities: {}, |
| 59 | 53 | clientInfo: { name: "executor-probe", version: "0" }, |
| 60 | 54 | }, |
| 61 | 55 | }); |
| 62 | 56 | |
| 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 | − | |
| 72 | 57 | /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s |
| 73 | 58 | * `Response.headers` already lower-cases, but we normalise explicitly |
| 74 | 59 | * to stay robust against test mocks that construct `Headers` loosely. */ |
| @@ -400,9 +385,10 @@ export const probeMcpEndpointShape = ( · 325 unmodified lines | ||
| 400 | 385 | .execute(postRequest) |
| 401 | 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 | 392 | let getRequest = HttpClientRequest.get(url.toString()).pipe( |
| 407 | 393 | HttpClientRequest.setHeader("accept", "text/event-stream"), |
| 408 | 394 | ); |
| @@ -412,42 +398,15 @@ export const probeMcpEndpointShape = ( · 3 unmodified lines | ||
| 412 | 398 | const getResponse = yield* client |
| 413 | 399 | .execute(getRequest) |
| 414 | 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 | 406 | kind: "not-mcp", |
| 420 | 407 | category: "wrong-shape", |
| 421 | 408 | reason: `unexpected status ${postResponse.status} for initialize`, |
| 422 | 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 | 410 | }).pipe( |
| 452 | 411 | Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer), |
| 453 | 412 | Effect.mapError( |
| @@ -3,9 +3,8 @@ · 2 unmodified lines | ||
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | // |
| 5 | 5 | // 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` | |
| 9 | 8 | // that crashes workerd at module instantiation with SIGSEGV (prod bundles |
| 10 | 9 | // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not). |
| 11 | 10 | // |
| @@ -14,7 +13,7 @@ · 2 unmodified lines | ||
| 14 | 13 | // the import and therefore never touch `node:child_process`. |
| 15 | 14 | // --------------------------------------------------------------------------- |
| 16 | 15 | |
| 17 | −import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; | |
| 16 | +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | |
| 18 | 17 | |
| 19 | 18 | export type StdioTransportConfig = { |
| 20 | 19 | readonly command: string; |
| @@ -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 | −); | |
| @@ -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 | −}); | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { expect, layer } from "@effect/vitest"; |
| 2 | 2 | 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"; | |
| 4 | 5 | import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| 5 | 6 | |
| 6 | 7 | import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; |
| @@ -15,10 +16,7 @@ const createGreetingMcpServer = () => · 8 unmodified lines | ||
| 15 | 16 | }); |
| 16 | 17 | |
| 17 | 18 | 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" }); | |
| 22 | 20 | const transport = new StreamableHTTPClientTransport(new URL(endpoint), { |
| 23 | 21 | requestInit: { |
| 24 | 22 | headers: { authorization: `Bearer ${accessToken}` }, |
| @@ -31,20 +31,6 @@ export type McpRemoteTransport = typeof McpRemoteTransport.Type; · 30 unmodified lines | ||
| 31 | 31 | export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); |
| 32 | 32 | export 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. */ | |
| 45 | −export const McpStdioVersionNegotiation = Schema.Literals(["legacy", "auto"]); | |
| 46 | −export type McpStdioVersionNegotiation = typeof McpStdioVersionNegotiation.Type; | |
| 47 | − | |
| 48 | 34 | // --------------------------------------------------------------------------- |
| 49 | 35 | // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) |
| 50 | 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 | 208 | env: Schema.optional(StringMap), |
| 223 | 209 | /** Working directory */ |
| 224 | 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 | 211 | /** Declared auth methods — a single `stdio_env` method naming the secret env |
| 229 | 212 | * vars, or `none`. A connection's `template` picks one by slug, exactly as |
| 230 | 213 | * for remote servers. Optional so pre-revamp stdio configs (which had no |
| @@ -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"; | |
| 2 | 2 | import * as http from "node:http"; |
| 3 | 3 | // Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients. |
| 4 | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 5 | 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 6 | −import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; | |
| 7 | 6 | import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| 8 | 7 | import z from "zod"; |
| 9 | 8 | |
| @@ -59,22 +58,6 @@ const writeText = (response: http.ServerResponse, status: number, body: string) · 49 unmodified lines | ||
| 59 | 58 | response.end(body); |
| 60 | 59 | }; |
| 61 | 60 | |
| 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 | − | |
| 78 | 61 | const isMcpPath = (url: string, path: string): boolean => { |
| 79 | 62 | const parsed = new URL(url, "http://executor.test"); |
| 80 | 63 | return parsed.pathname === path; |
| @@ -175,24 +158,6 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 94 unmodified lines | ||
| 175 | 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 | 161 | const transport = new StreamableHTTPServerTransport({ |
| 197 | 162 | sessionIdGenerator: () => crypto.randomUUID(), |
| 198 | 163 | onsessioninitialized: (sid) => { |
| @@ -208,7 +173,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 9 unmodified lines | ||
| 208 | 173 | catch: (cause) => new McpTestServerError({ cause }), |
| 209 | 174 | }); |
| 210 | 175 | yield* Effect.tryPromise({ |
| 211 | − try: () => transport.handleRequest(request, response, parsedBody), | |
| 176 | + try: () => transport.handleRequest(request, response), | |
| 212 | 177 | catch: (cause) => new McpTestServerError({ cause }), |
| 213 | 178 | }); |
| 214 | 179 | }).pipe( |
| @@ -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. | |