* 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 | "@executor-js/react": "workspace:*", | 728 | "@executor-js/react": "workspace:*", |
| 729 | "@executor-js/runtime-quickjs": "workspace:*", | 729 | "@executor-js/runtime-quickjs": "workspace:*", |
| 730 | "@modelcontextprotocol/ext-apps": "^1.7.4", | 730 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 731 | "@modelcontextprotocol/sdk": "^1.29.0", | 731 | "@modelcontextprotocol/sdk": "^1.12.1", |
| 732 | "@tanstack/react-query": "^5.99.0", | 732 | "@tanstack/react-query": "^5.99.0", |
| 733 | "effect": "catalog:", | 733 | "effect": "catalog:", |
| 734 | "esbuild": "^0.27.7", | 734 | "esbuild": "^0.27.7", |
| @@ -1001,8 +1001,6 @@ · 266 unmodified lines | |||
| 1001 | "@effect/platform-node": "catalog:", | 1001 | "@effect/platform-node": "catalog:", |
| 1002 | "@executor-js/config": "workspace:*", | 1002 | "@executor-js/config": "workspace:*", |
| 1003 | "@executor-js/sdk": "workspace:*", | 1003 | "@executor-js/sdk": "workspace:*", |
| 1004 | "@modelcontextprotocol/client": "2.0.0", | ||
| 1005 | "@modelcontextprotocol/core": "2.0.0", | ||
| 1006 | "@modelcontextprotocol/sdk": "^1.29.0", | 1004 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 1007 | "zod": "4.3.6", | 1005 | "zod": "4.3.6", |
| 1008 | }, | 1006 | }, |
| @@ -1011,7 +1009,6 @@ · 2 unmodified lines | |||
| 1011 | "@effect/vitest": "catalog:", | 1009 | "@effect/vitest": "catalog:", |
| 1012 | "@executor-js/api": "workspace:*", | 1010 | "@executor-js/api": "workspace:*", |
| 1013 | "@executor-js/react": "workspace:*", | 1011 | "@executor-js/react": "workspace:*", |
| 1014 | "@modelcontextprotocol/server": "2.0.0", | ||
| 1015 | "@types/node": "catalog:", | 1012 | "@types/node": "catalog:", |
| 1016 | "@types/react": "catalog:", | 1013 | "@types/react": "catalog:", |
| 1017 | "bun-types": "catalog:", | 1014 | "bun-types": "catalog:", |
| @@ -146,36 +146,6 @@ scenario( · 145 unmodified lines | |||
| 146 | declTools.map((t) => t.name), | 146 | declTools.map((t) => t.name), |
| 147 | "connecting with the secret discovers the env-gated tool", | 147 | "connecting with the secret discovers the env-gated tool", |
| 148 | ).toContain("whoami"); | 148 | ).toContain("whoami"); |
| 149 | |||
| 150 | // --- versionNegotiation "auto" survives the API → config → connector | ||
| 151 | // path and still reaches a legacy server: the probe gets the fixture's | ||
| 152 | // method-not-found for `server/discover` (a definitive legacy verdict) | ||
| 153 | // and falls back to `initialize`. Modern-era acceptance against a real | ||
| 154 | // legacy-disabled SDK v2 server lives in the plugin's | ||
| 155 | // stdio-negotiation.test.ts. --- | ||
| 156 | const autoSlug = "e2e-stdio-auto"; | ||
| 157 | yield* client.mcp.addServer({ | ||
| 158 | payload: { | ||
| 159 | transport: "stdio", | ||
| 160 | name: "E2E Stdio Auto", | ||
| 161 | command: "node", | ||
| 162 | args: [FIXTURE], | ||
| 163 | versionNegotiation: "auto", | ||
| 164 | slug: autoSlug, | ||
| 165 | }, | ||
| 166 | }); | ||
| 167 | |||
| 168 | const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); | ||
| 169 | expect( | ||
| 170 | JSON.stringify(autoStored?.config ?? {}), | ||
| 171 | "the negotiation mode is persisted on the integration config", | ||
| 172 | ).toContain('"versionNegotiation":"auto"'); | ||
| 173 | |||
| 174 | const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); | ||
| 175 | expect( | ||
| 176 | autoTools.map((t) => t.name), | ||
| 177 | "auto negotiation falls back to legacy and still discovers tools", | ||
| 178 | ).toContain("echo_tool"); | ||
| 179 | }), | 149 | }), |
| 180 | ); | 150 | ); |
| 181 | }), | 151 | }), |
| @@ -69,7 +69,7 @@ · 68 unmodified lines | |||
| 69 | "@executor-js/react": "workspace:*", | 69 | "@executor-js/react": "workspace:*", |
| 70 | "@executor-js/runtime-quickjs": "workspace:*", | 70 | "@executor-js/runtime-quickjs": "workspace:*", |
| 71 | "@modelcontextprotocol/ext-apps": "^1.7.4", | 71 | "@modelcontextprotocol/ext-apps": "^1.7.4", |
| 72 | "@modelcontextprotocol/sdk": "^1.29.0", | 72 | "@modelcontextprotocol/sdk": "^1.12.1", |
| 73 | "@tanstack/react-query": "^5.99.0", | 73 | "@tanstack/react-query": "^5.99.0", |
| 74 | "effect": "catalog:", | 74 | "effect": "catalog:", |
| 75 | "esbuild": "^0.27.7", | 75 | "esbuild": "^0.27.7", |
| @@ -65,8 +65,6 @@ · 64 unmodified lines | |||
| 65 | "@effect/platform-node": "catalog:", | 65 | "@effect/platform-node": "catalog:", |
| 66 | "@executor-js/config": "workspace:*", | 66 | "@executor-js/config": "workspace:*", |
| 67 | "@executor-js/sdk": "workspace:*", | 67 | "@executor-js/sdk": "workspace:*", |
| 68 | "@modelcontextprotocol/client": "2.0.0", | ||
| 69 | "@modelcontextprotocol/core": "2.0.0", | ||
| 70 | "@modelcontextprotocol/sdk": "^1.29.0", | 68 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 71 | "zod": "4.3.6" | 69 | "zod": "4.3.6" |
| 72 | }, | 70 | }, |
| @@ -75,7 +73,6 @@ · 2 unmodified lines | |||
| 75 | "@effect/vitest": "catalog:", | 73 | "@effect/vitest": "catalog:", |
| 76 | "@executor-js/api": "workspace:*", | 74 | "@executor-js/api": "workspace:*", |
| 77 | "@executor-js/react": "workspace:*", | 75 | "@executor-js/react": "workspace:*", |
| 78 | "@modelcontextprotocol/server": "2.0.0", | ||
| 79 | "@types/node": "catalog:", | 76 | "@types/node": "catalog:", |
| 80 | "@types/react": "catalog:", | 77 | "@types/react": "catalog:", |
| 81 | "bun-types": "catalog:", | 78 | "bun-types": "catalog:", |
| @@ -57,10 +57,6 @@ const AddStdioServerPayload = Schema.Struct({ · 56 unmodified lines | |||
| 57 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ | 57 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ |
| 58 | env: Schema.optional(StringMap), | 58 | env: Schema.optional(StringMap), |
| 59 | cwd: Schema.optional(Schema.String), | 59 | cwd: Schema.optional(Schema.String), |
| 60 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec | ||
| 61 | * 2026-07-28) for modern-only servers; default is the legacy `initialize` | ||
| 62 | * handshake. */ | ||
| 63 | versionNegotiation: Schema.optional(Schema.Literals(["legacy", "auto"])), | ||
| 64 | slug: Schema.optional(Schema.String), | 60 | slug: Schema.optional(Schema.String), |
| 65 | }); | 61 | }); |
| 66 | 62 | ||
| @@ -39,7 +39,6 @@ const toServerInput = ( · 38 unmodified lines | |||
| 39 | envVars?: readonly string[]; | 39 | envVars?: readonly string[]; |
| 40 | env?: Record<string, string>; | 40 | env?: Record<string, string>; |
| 41 | cwd?: string; | 41 | cwd?: string; |
| 42 | versionNegotiation?: "legacy" | "auto"; | ||
| 43 | slug?: string; | 42 | slug?: string; |
| 44 | }; | 43 | }; |
| 45 | return { | 44 | return { |
| @@ -51,7 +50,6 @@ const toServerInput = ( · 5 unmodified lines | |||
| 51 | envVars: p.envVars ? [...p.envVars] : undefined, | 50 | envVars: p.envVars ? [...p.envVars] : undefined, |
| 52 | env: p.env, | 51 | env: p.env, |
| 53 | cwd: p.cwd, | 52 | cwd: p.cwd, |
| 54 | versionNegotiation: p.versionNegotiation, | ||
| 55 | slug: p.slug, | 53 | slug: p.slug, |
| 56 | }; | 54 | }; |
| 57 | } | 55 | } |
| @@ -3,10 +3,6 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines | |||
| 3 | import type { McpConnection, McpConnector } from "./connection"; | 3 | import type { McpConnection, McpConnector } from "./connection"; |
| 4 | import type { McpInvocationError } from "./errors"; | 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 | const IDLE_TTL_MS = 5 * 60 * 1_000; | 6 | const IDLE_TTL_MS = 5 * 60 * 1_000; |
| 11 | 7 | ||
| 12 | type IdleConnection = { | 8 | type IdleConnection = { |
| @@ -1,18 +1,16 @@ | |||
| 1 | import { | 1 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 2 | Client, | 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 3 | SSEClientTransport, | 3 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| 4 | StreamableHTTPClientTransport, | 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 5 | type FetchLike, | 5 | import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; |
| 6 | type OAuthClientProvider, | 6 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; |
| 7 | } from "@modelcontextprotocol/client"; | ||
| 8 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; | ||
| 9 | import { Effect, Layer, Predicate, Stream } from "effect"; | 7 | import { Effect, Layer, Predicate, Stream } from "effect"; |
| 10 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 8 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 11 | 9 | ||
| 12 | // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module | 10 | // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module |
| 13 | // (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and | 11 | // (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process` |
| 14 | // `cross-spawn` eagerly at evaluation time, which crashes workerd (including | 12 | // at evaluation time, which crashes workerd (incl. vitest-pool-workers) at |
| 15 | // vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set | 13 | // SIGSEGV on module instantiation. Cloud callers set |
| 16 | // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below; | 14 | // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below; |
| 17 | // prod bundles that DO use stdio load it via a dynamic import inside the | 15 | // prod bundles that DO use stdio load it via a dynamic import inside the |
| 18 | // stdio branch of `createMcpConnector`. | 16 | // stdio branch of `createMcpConnector`. |
| @@ -203,13 +201,12 @@ const fetchFromHttpClientLayer = ( · 184 unmodified lines | |||
| 203 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The | 201 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The |
| 204 | // cfworker validator does not use code generation and works in every | 202 | // cfworker validator does not use code generation and works in every |
| 205 | // runtime we ship to. | 203 | // runtime we ship to. |
| 206 | const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client => | 204 | const createClient = (): Client => |
| 207 | new Client( | 205 | new Client( |
| 208 | { name: "executor-mcp", version: "0.1.0" }, | 206 | { name: "executor-mcp", version: "0.1.0" }, |
| 209 | { | 207 | { |
| 210 | capabilities: { elicitation: { form: {}, url: {} } }, | 208 | capabilities: { elicitation: { form: {}, url: {} } }, |
| 211 | jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), | 209 | jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), |
| 212 | ...(versionNegotiation === undefined ? {} : { versionNegotiation }), | ||
| 213 | }, | 210 | }, |
| 214 | ); | 211 | ); |
| 215 | 212 | ||
| @@ -250,10 +247,9 @@ const connectionFailure = ( · 34 unmodified lines | |||
| 250 | const connectClient = (input: { | 247 | const connectClient = (input: { |
| 251 | transport: string; | 248 | transport: string; |
| 252 | createTransport: () => Parameters<Client["connect"]>[0]; | 249 | createTransport: () => Parameters<Client["connect"]>[0]; |
| 253 | versionNegotiation?: { readonly mode: "auto" }; | ||
| 254 | }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> => | 250 | }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> => |
| 255 | Effect.gen(function* () { | 251 | Effect.gen(function* () { |
| 256 | const client = createClient(input.versionNegotiation); | 252 | const client = createClient(); |
| 257 | const transportInstance = input.createTransport(); | 253 | const transportInstance = input.createTransport(); |
| 258 | 254 | ||
| 259 | yield* Effect.tryPromise({ | 255 | yield* Effect.tryPromise({ |
| @@ -266,15 +262,6 @@ const connectClient = (input: { · 6 unmodified lines | |||
| 266 | catch: (cause) => | 262 | catch: (cause) => |
| 267 | connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), | 263 | connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), |
| 268 | }).pipe( | 264 | }).pipe( |
| 269 | // The negotiated era ("modern" = 2026-07-28 server/discover, "legacy" = | ||
| 270 | // 2025 initialize) is otherwise invisible: both eras list and call tools | ||
| 271 | // identically, so traces are the one place an integration author can | ||
| 272 | // verify which handshake a connection actually used. | ||
| 273 | Effect.tap(() => | ||
| 274 | Effect.annotateCurrentSpan({ | ||
| 275 | "plugin.mcp.protocol_era": client.getProtocolEra() ?? "unknown", | ||
| 276 | }), | ||
| 277 | ), | ||
| 278 | Effect.withSpan("plugin.mcp.connection.handshake", { | 265 | Effect.withSpan("plugin.mcp.connection.handshake", { |
| 279 | attributes: { "plugin.mcp.transport": input.transport }, | 266 | attributes: { "plugin.mcp.transport": input.transport }, |
| 280 | }), | 267 | }), |
| @@ -313,12 +300,6 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 32 unmodified lines | |||
| 313 | 300 | ||
| 314 | return yield* connectClient({ | 301 | return yield* connectClient({ |
| 315 | transport: "stdio", | 302 | transport: "stdio", |
| 316 | // Opt-in per integration (default legacy) — see | ||
| 317 | // `McpStdioVersionNegotiation` for why stdio does not follow the | ||
| 318 | // remote transport's unconditional auto. | ||
| 319 | ...(input.versionNegotiation === "auto" | ||
| 320 | ? { versionNegotiation: { mode: "auto" as const } } | ||
| 321 | : {}), | ||
| 322 | createTransport: () => | 303 | createTransport: () => |
| 323 | createStdioTransport({ | 304 | createStdioTransport({ |
| 324 | command, | 305 | command, |
| @@ -338,13 +319,8 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 13 unmodified lines | |||
| 338 | 319 | ||
| 339 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); | 320 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); |
| 340 | 321 | ||
| 341 | // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable | ||
| 342 | // HTTP. SSE is a legacy-only transport; stdio negotiates per the | ||
| 343 | // integration's `versionNegotiation` (default legacy — see the stdio | ||
| 344 | // branch above). | ||
| 345 | const connectStreamableHttp = connectClient({ | 322 | const connectStreamableHttp = connectClient({ |
| 346 | transport: "streamable-http", | 323 | transport: "streamable-http", |
| 347 | versionNegotiation: { mode: "auto" }, | ||
| 348 | createTransport: () => | 324 | createTransport: () => |
| 349 | new StreamableHTTPClientTransport(endpoint, { | 325 | new StreamableHTTPClientTransport(endpoint, { |
| 350 | requestInit, | 326 | requestInit, |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Predicate, Schema, Semaphore } from "effect"; | 2 | import { Effect, Predicate, Schema, Semaphore } from "effect"; |
| 3 | import type { JsonSchemaType } from "@modelcontextprotocol/client"; | 3 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; |
| 4 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; | 4 | import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | AuthTemplateSlug, | 7 | AuthTemplateSlug, |
| @@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { · 218 unmodified lines | |||
| 226 | ]), | 226 | ]), |
| 227 | ); | 227 | ); |
| 228 | expect(schema?.outputTypeScript).toContain('type: "text"'); | 228 | expect(schema?.outputTypeScript).toContain('type: "text"'); |
| 229 | expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;"); | 229 | expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }"); |
| 230 | 230 | ||
| 231 | const result = yield* executor.execute( | 231 | const result = yield* executor.execute( |
| 232 | simpleEcho.address, | 232 | simpleEcho.address, |
| @@ -1,5 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; | ||
| 3 | 2 | ||
| 4 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors | 3 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors |
| 5 | import { insufficientScopeFromCause } from "./http-status"; | 4 | import { insufficientScopeFromCause } from "./http-status"; |
| @@ -10,8 +9,7 @@ import { insufficientScopeFromCause } from "./http-status"; · 4 unmodified lines | |||
| 10 | // - with an authProvider (the production OAuth path): the StreamableHTTP | 9 | // - with an authProvider (the production OAuth path): the StreamableHTTP |
| 11 | // transport consumes the insufficient_scope challenge itself, retries | 10 | // transport consumes the insufficient_scope challenge itself, retries |
| 12 | // with the broader scope, and only when THAT fails throws the fixed | 11 | // with the broader scope, and only when THAT fails throws the fixed |
| 13 | // typed `InsufficientScopeError`, or after retry exhaustion the fixed | 12 | // "Server returned 403 after trying upscoping" message. |
| 14 | // `SdkHttpError` step-up message. | ||
| 15 | describe("insufficientScopeFromCause", () => { | 13 | describe("insufficientScopeFromCause", () => { |
| 16 | it("detects the OAuth error body embedded in a transport message", () => { | 14 | it("detects the OAuth error body embedded in a transport message", () => { |
| 17 | expect( | 15 | expect( |
| @@ -33,21 +31,9 @@ describe("insufficientScopeFromCause", () => { · 15 unmodified lines | |||
| 33 | ).toBe(true); | 31 | ).toBe(true); |
| 34 | }); | 32 | }); |
| 35 | 33 | ||
| 36 | it("detects the SDK's typed insufficient-scope failure", () => { | 34 | it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => { |
| 37 | expect( | 35 | expect( |
| 38 | insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })), | 36 | insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")), |
| 39 | ).toBe(true); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("detects the SDK's exhausted step-up failure (the authProvider path)", () => { | ||
| 43 | expect( | ||
| 44 | insufficientScopeFromCause( | ||
| 45 | new SdkHttpError( | ||
| 46 | SdkErrorCode.ClientHttpForbidden, | ||
| 47 | "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)", | ||
| 48 | { status: 403 }, | ||
| 49 | ), | ||
| 50 | ), | ||
| 51 | ).toBe(true); | 37 | ).toBe(true); |
| 52 | }); | 38 | }); |
| 53 | 39 | ||
| @@ -1,8 +1,7 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Extract the HTTP status from an MCP SDK transport error. The SDK surfaces | 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`, | 3 | // transport failures two ways: a `StreamableHTTPError` subclass carrying a |
| 4 | // and an `SseError` carrying a numeric `code`. The SSE transport also retains | 4 | // numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`. |
| 5 | // its historic POST-failure message for errors created below EventSource. | ||
| 6 | // Shared by the invoke path (classifies tool-call failures) and the connect | 5 | // Shared by the invoke path (classifies tool-call failures) and the connect |
| 7 | // path (so a 401/403 during the handshake reaches the liveness health check). | 6 | // path (so a 401/403 during the handshake reaches the liveness health check). |
| 8 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| @@ -10,13 +9,13 @@ · 1 unmodified line | |||
| 10 | import { Option, Schema } from "effect"; | 9 | import { Option, Schema } from "effect"; |
| 11 | 10 | ||
| 12 | import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; | 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 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); | 14 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); |
| 16 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); | 15 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); |
| 17 | 16 | ||
| 18 | // V2 still constructs this exact message in SSEClientTransport._send. A format | 17 | // Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify |
| 19 | // drift just yields undefined (generic error, no crash). | 18 | // on SDK bumps. A format drift just yields undefined (generic error, no crash). |
| 20 | const statusFromSsePostError = (cause: unknown): number | undefined => | 19 | const statusFromSsePostError = (cause: unknown): number | undefined => |
| 21 | Option.match(decodeSsePostErrorCause(cause), { | 20 | Option.match(decodeSsePostErrorCause(cause), { |
| 22 | onNone: () => undefined, | 21 | onNone: () => undefined, |
| @@ -27,36 +26,32 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 4 unmodified lines | |||
| 27 | }, | 26 | }, |
| 28 | }); | 27 | }); |
| 29 | 28 | ||
| 30 | const statusFromTypedTransportError = (cause: unknown): number | undefined => { | 29 | const statusFromStreamableHttpError = (cause: unknown): number | undefined => { |
| 31 | if (SdkHttpError.isInstance(cause)) return cause.status; | 30 | // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK exposes transport HTTP failures as this Error subclass; protocol errors can carry the same numeric code |
| 32 | if (SseError.isInstance(cause)) { | 31 | if (!(cause instanceof StreamableHTTPError)) return undefined; |
| 33 | const code = cause.code; | 32 | const code = cause.code; |
| 34 | return code !== undefined && code >= 100 && code <= 599 ? code : undefined; | 33 | return code !== undefined && code >= 100 && code <= 599 ? code : undefined; |
| 35 | } | ||
| 36 | return undefined; | ||
| 37 | }; | 34 | }; |
| 38 | 35 | ||
| 39 | export const httpStatusFromCause = (cause: unknown): number | undefined => | 36 | export const httpStatusFromCause = (cause: unknown): number | undefined => |
| 40 | statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause); | 37 | statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause); |
| 41 | 38 | ||
| 42 | // The SDK embeds the upstream response text in the transport error message | 39 | // The SDK embeds the upstream response text in the transport error message |
| 43 | // ("Error POSTing to endpoint: <body>"), which is the only place a 403's body | 40 | // ("Error POSTing to endpoint: <body>"), which is the only place a 403's body |
| 44 | // survives for connections without an authProvider. For OAuth connections the | 41 | // survives for connections without an authProvider. For OAuth connections the |
| 45 | // StreamableHTTP transport consumes the insufficient_scope challenge itself. | 42 | // StreamableHTTP transport consumes the insufficient_scope challenge ITSELF: |
| 46 | // V2 throws `InsufficientScopeError` when configured not to reauthorize; after | 43 | // it re-runs auth requesting the broader scope, and only when that upscoped |
| 47 | // exhausting its step-up retries it throws `SdkHttpError` with the exact fixed | 44 | // retry still 403s does it throw — with the fixed message matched below |
| 48 | // message matched below (verified against the installed v2 transport source). | 45 | // (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on |
| 49 | // Both paths mean the same thing: the grant does not cover the operation, and | 46 | // SDK bumps). Both paths mean the same thing: the grant does not cover the |
| 50 | // re-running the identical flow cannot help. Strict matching | 47 | // operation, and re-running the identical flow cannot help. Strict matching |
| 51 | // (exact serialized field forms via the shared core detector, or the SDK's | 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. | 49 | // exact upscoping message) — a miss stays on the generic auth path. |
| 53 | const SDK_STEP_UP_EXHAUSTED_RE = | 50 | const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/; |
| 54 | /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/; | ||
| 55 | 51 | ||
| 56 | export const insufficientScopeFromCause = (cause: unknown): boolean => | 52 | export const insufficientScopeFromCause = (cause: unknown): boolean => |
| 57 | InsufficientScopeError.isInstance(cause) || | ||
| 58 | Option.match(decodeSsePostErrorCause(cause), { | 53 | Option.match(decodeSsePostErrorCause(cause), { |
| 59 | onNone: () => false, | 54 | onNone: () => false, |
| 60 | onSome: ({ message }) => | 55 | onSome: ({ message }) => |
| 61 | insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message), | 56 | insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message), |
| 62 | }); | 57 | }); |
| @@ -2,12 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { Effect, Predicate } from "effect"; | 2 | import { Effect, Predicate } from "effect"; |
| 3 | import { HttpServerResponse } from "effect/unstable/http"; | 3 | import { HttpServerResponse } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import { | 5 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 6 | ProtocolError, | 6 | import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 7 | SdkErrorCode, | 7 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; |
| 8 | SdkHttpError, | ||
| 9 | type OAuthClientProvider, | ||
| 10 | } from "@modelcontextprotocol/client"; | ||
| 11 | import { ElicitationResponse } from "@executor-js/sdk"; | 8 | import { ElicitationResponse } from "@executor-js/sdk"; |
| 12 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; | 9 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 13 | 10 | ||
| @@ -111,16 +108,14 @@ const invocationRejectionCases = [ · 97 unmodified lines | |||
| 111 | name: "wraps callTool rejection with a stable message and status", | 108 | name: "wraps callTool rejection with a stable message and status", |
| 112 | toolId: "blocked", | 109 | toolId: "blocked", |
| 113 | transport: "streamable-http", | 110 | transport: "streamable-http", |
| 114 | cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", { | 111 | cause: new StreamableHTTPError(401, "token=do-not-leak"), |
| 115 | status: 401, | ||
| 116 | }), | ||
| 117 | expectedStatus: 401 as number | undefined, | 112 | expectedStatus: 401 as number | undefined, |
| 118 | }, | 113 | }, |
| 119 | { | 114 | { |
| 120 | name: "does not treat MCP protocol error codes as HTTP statuses", | 115 | name: "does not treat MCP protocol error codes as HTTP statuses", |
| 121 | toolId: "protocol_error", | 116 | toolId: "protocol_error", |
| 122 | transport: "streamable-http", | 117 | transport: "streamable-http", |
| 123 | cause: new ProtocolError(401, "application-level do-not-leak"), | 118 | cause: new McpError(401, "application-level do-not-leak"), |
| 124 | expectedStatus: undefined, | 119 | expectedStatus: undefined, |
| 125 | }, | 120 | }, |
| 126 | { | 121 | { |
| @@ -8,7 +8,7 @@ · 7 unmodified lines | |||
| 8 | // legitimately retain state in that session. The pool keeps one idle | 8 | // legitimately retain state in that session. The pool keeps one idle |
| 9 | // connection per resolved identity and leases it exclusively per invoke; | 9 | // connection per resolved identity and leases it exclusively per invoke; |
| 10 | // stdio and callers without a pool remain strictly per-call. | 10 | // stdio and callers without a pool remain strictly per-call. |
| 11 | // 2. Installing a per-invocation `elicitation/create` handler that bridges | 11 | // 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges |
| 12 | // MCP's elicit capability into the host's elicit function threaded via | 12 | // MCP's elicit capability into the host's elicit function threaded via |
| 13 | // `InvokeToolInput.elicit`. | 13 | // `InvokeToolInput.elicit`. |
| 14 | // 3. Calling `client.callTool({ name, arguments })`. | 14 | // 3. Calling `client.callTool({ name, arguments })`. |
| @@ -16,7 +16,12 @@ · 1 unmodified line | |||
| 16 | 16 | ||
| 17 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; | 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 | import { | 26 | import { |
| 22 | ElicitationId, | 27 | ElicitationId, |
| @@ -61,10 +66,9 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines | |||
| 61 | 66 | ||
| 62 | const isUnknownToolCause = (cause: unknown, toolName: string): boolean => | 67 | const isUnknownToolCause = (cause: unknown, toolName: string): boolean => |
| 63 | // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass | 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 && | 69 | cause instanceof McpError && |
| 65 | (cause.code === ProtocolErrorCode.InvalidParams || | 70 | (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.MethodNotFound) && |
| 66 | cause.code === ProtocolErrorCode.MethodNotFound) && | 71 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's McpError, whose message carries the only unknown-tool discriminator the protocol provides |
| 67 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides | ||
| 68 | isUnknownToolMessage(cause.message, toolName); | 72 | isUnknownToolMessage(cause.message, toolName); |
| 69 | 73 | ||
| 70 | // --------------------------------------------------------------------------- | 74 | // --------------------------------------------------------------------------- |
| @@ -89,17 +93,6 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines | |||
| 89 | type McpElicitParams = typeof McpElicitParams.Type; | 93 | type McpElicitParams = typeof McpElicitParams.Type; |
| 90 | 94 | ||
| 91 | const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams); | 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 | const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => | 97 | const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => |
| 105 | params.mode === "url" | 98 | params.mode === "url" |
| @@ -114,7 +107,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 8 unmodified lines | |||
| 114 | }); | 107 | }); |
| 115 | 108 | ||
| 116 | const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => { | 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 | const params = decodeElicitParams(request.params); | 111 | const params = decodeElicitParams(request.params); |
| 119 | const req = toElicitationRequest(params); | 112 | const req = toElicitationRequest(params); |
| 120 | // Use runPromiseExit so we can inspect typed failures — `elicit` | 113 | // Use runPromiseExit so we can inspect typed failures — `elicit` |
| @@ -126,9 +119,7 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic · 5 unmodified lines | |||
| 126 | const response = exit.value; | 119 | const response = exit.value; |
| 127 | return { | 120 | return { |
| 128 | action: response.action, | 121 | action: response.action, |
| 129 | ...(response.action === "accept" && response.content | 122 | ...(response.action === "accept" && response.content ? { content: response.content } : {}), |
| 130 | ? { content: decodeElicitContent(response.content) } | ||
| 131 | : {}), | ||
| 132 | }; | 123 | }; |
| 133 | } | 124 | } |
| 134 | const failure = exit.cause.reasons.find(Cause.isFailReason); | 125 | const failure = exit.cause.reasons.find(Cause.isFailReason); |
| @@ -158,7 +149,7 @@ const installToolListChangedHandler = ( · 23 unmodified lines | |||
| 158 | onToolListChanged: (() => void) | undefined, | 149 | onToolListChanged: (() => void) | undefined, |
| 159 | ): void => { | 150 | ): void => { |
| 160 | if (!onToolListChanged) return; | 151 | if (!onToolListChanged) return; |
| 161 | client.setNotificationHandler("notifications/tools/list_changed", () => { | 152 | client.setNotificationHandler(ToolListChangedNotificationSchema, () => { |
| 162 | onToolListChanged(); | 153 | onToolListChanged(); |
| 163 | }); | 154 | }); |
| 164 | }; | 155 | }; |
| @@ -198,8 +189,8 @@ const useConnection = ( · 33 unmodified lines | |||
| 198 | }); | 189 | }); |
| 199 | } | 190 | } |
| 200 | const status = httpStatusFromCause(cause); | 191 | const status = httpStatusFromCause(cause); |
| 201 | // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes | 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 |
| 202 | const protocolFailure = cause instanceof ProtocolError; | 193 | const protocolFailure = cause instanceof McpError; |
| 203 | return new McpInvocationError({ | 194 | return new McpInvocationError({ |
| 204 | toolName, | 195 | toolName, |
| 205 | message: `MCP tool call failed for ${toolName}`, | 196 | message: `MCP tool call failed for ${toolName}`, |
| @@ -1,8 +1,9 @@ | |||
| 1 | import { Effect, Layer, Option, Result, Schema } from "effect"; | 1 | import { Effect, Layer, Option, Result, Schema } from "effect"; |
| 2 | import type { HttpClient } from "effect/unstable/http"; | 2 | import type { HttpClient } from "effect/unstable/http"; |
| 3 | 3 | ||
| 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/client"; | 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 5 | import { CallToolResultSchema } from "@modelcontextprotocol/core"; | 5 | import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 6 | import * as z from "zod/v4"; | ||
| 6 | 7 | ||
| 7 | import { | 8 | import { |
| 8 | authToolFailure, | 9 | authToolFailure, |
| @@ -60,7 +61,6 @@ import { · 51 unmodified lines | |||
| 60 | expandMcpAuthMethodInputs, | 61 | expandMcpAuthMethodInputs, |
| 61 | mcpAuthMethodFromShorthand, | 62 | mcpAuthMethodFromShorthand, |
| 62 | normalizeMcpAuthMethods, | 63 | normalizeMcpAuthMethods, |
| 63 | McpStdioVersionNegotiation, | ||
| 64 | parseMcpIntegrationConfig, | 64 | parseMcpIntegrationConfig, |
| 65 | type McpIntegrationConfig as McpIntegrationConfigType, | 65 | type McpIntegrationConfig as McpIntegrationConfigType, |
| 66 | type McpStdioEnvMethod, | 66 | type McpStdioEnvMethod, |
| @@ -207,11 +207,6 @@ const McpStdioServerInputSchema = Schema.Struct({ · 140 unmodified lines | |||
| 207 | * instead and leaves the values to the connect step. */ | 207 | * instead and leaves the values to the connect step. */ |
| 208 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 208 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 209 | cwd: Schema.optional(Schema.String), | 209 | cwd: Schema.optional(Schema.String), |
| 210 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec | ||
| 211 | * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize` | ||
| 212 | * handshake — the right call for spawn-per-call servers, where the auto | ||
| 213 | * probe costs an extra child process per connect. */ | ||
| 214 | versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | ||
| 215 | slug: Schema.optional(Schema.String), | 210 | slug: Schema.optional(Schema.String), |
| 216 | }); | 211 | }); |
| 217 | 212 | ||
| @@ -375,7 +370,6 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 157 unmodified lines | |||
| 375 | command: input.command, | 370 | command: input.command, |
| 376 | args: input.args ? [...input.args] : undefined, | 371 | args: input.args ? [...input.args] : undefined, |
| 377 | cwd: input.cwd, | 372 | cwd: input.cwd, |
| 378 | versionNegotiation: input.versionNegotiation, | ||
| 379 | authenticationTemplate: | 373 | authenticationTemplate: |
| 380 | vars.length > 0 | 374 | vars.length > 0 |
| 381 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] | 375 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] |
| @@ -398,7 +392,7 @@ type JsonSchemaObject = Record<string, unknown> & { · 16 unmodified lines | |||
| 398 | readonly properties?: Record<string, unknown>; | 392 | readonly properties?: Record<string, unknown>; |
| 399 | }; | 393 | }; |
| 400 | 394 | ||
| 401 | const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema(); | 395 | const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; |
| 402 | 396 | ||
| 403 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { | 397 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { |
| 404 | const defaultStructuredContentSchema = | 398 | const defaultStructuredContentSchema = |
| @@ -499,9 +493,7 @@ export const userFacingProbeMessage = ( · 94 unmodified lines | |||
| 499 | // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the | 493 | // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the |
| 500 | // transport sends it as a Bearer header. Refresh is core's responsibility | 494 | // transport sends it as a Bearer header. Refresh is core's responsibility |
| 501 | // (the connection row carries the OAuth grant); this adapter never initiates | 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 | 496 | // a new flow and fails loudly if the SDK tries to. |
| 503 | // with the authorization-server issuer and offers scoped invalidation; this | ||
| 504 | // single-token boundary intentionally persists neither. | ||
| 505 | // --------------------------------------------------------------------------- | 497 | // --------------------------------------------------------------------------- |
| 506 | 498 | ||
| 507 | const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ | 499 | const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ |
| @@ -594,7 +586,6 @@ const buildConnectorInput = ( · 86 unmodified lines | |||
| 594 | args: config.args, | 586 | args: config.args, |
| 595 | env: Object.keys(env).length > 0 ? env : undefined, | 587 | env: Object.keys(env).length > 0 ? env : undefined, |
| 596 | cwd: config.cwd, | 588 | cwd: config.cwd, |
| 597 | versionNegotiation: config.versionNegotiation, | ||
| 598 | } satisfies McpStdioIntegrationConfig); | 589 | } satisfies McpStdioIntegrationConfig); |
| 599 | } | 590 | } |
| 600 | 591 | ||
| @@ -1053,7 +1044,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 452 unmodified lines | |||
| 1053 | command: config.command, | 1044 | command: config.command, |
| 1054 | args: config.args, | 1045 | args: config.args, |
| 1055 | cwd: config.cwd, | 1046 | cwd: config.cwd, |
| 1056 | versionNegotiation: config.versionNegotiation, | ||
| 1057 | authenticationTemplate: hasEnv | 1047 | authenticationTemplate: hasEnv |
| 1058 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] | 1048 | ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] |
| 1059 | : [{ slug: "none", kind: "none" }], | 1049 | : [{ slug: "none", kind: "none" }], |
| @@ -1,12 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Layer, Ref } from "effect"; | 2 | import { Effect, Ref } from "effect"; |
| 3 | import { | 3 | import { HttpServerResponse } from "effect/unstable/http"; |
| 4 | HttpClient, | ||
| 5 | HttpClientError, | ||
| 6 | HttpClientRequest, | ||
| 7 | HttpClientResponse, | ||
| 8 | HttpServerResponse, | ||
| 9 | } from "effect/unstable/http"; | ||
| 10 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; | 4 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 11 | 5 | ||
| 12 | import { probeMcpEndpointShape } from "./probe-shape"; | 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 | it.effect("rejects 2xx with HTML body as wrong-shape", () => | 323 | it.effect("rejects 2xx with HTML body as wrong-shape", () => |
| 412 | withServer( | 324 | withServer( |
| 413 | () => | 325 | () => |
| @@ -13,7 +13,7 @@ · 12 unmodified lines | |||
| 13 | // and (b) plenty of real MCP servers authenticate with static API | 13 | // and (b) plenty of real MCP servers authenticate with static API |
| 14 | // keys and publish no OAuth metadata at all (e.g. cubic.dev). | 14 | // keys and publish no OAuth metadata at all (e.g. cubic.dev). |
| 15 | // | 15 | // |
| 16 | // The primary probe issues an unauth JSON-RPC `initialize` POST and accepts | 16 | // The probe issues an unauth JSON-RPC `initialize` POST and accepts |
| 17 | // only the wire shapes a real MCP server can return: | 17 | // only the wire shapes a real MCP server can return: |
| 18 | // | 18 | // |
| 19 | // - 2xx with `Content-Type: text/event-stream` — streamable HTTP | 19 | // - 2xx with `Content-Type: text/event-stream` — streamable HTTP |
| @@ -30,14 +30,8 @@ · 10 unmodified lines | |||
| 30 | // only accepts 2xx with `text/event-stream` or the same 401+Bearer | 30 | // only accepts 2xx with `text/event-stream` or the same 401+Bearer |
| 31 | // shape. | 31 | // shape. |
| 32 | // | 32 | // |
| 33 | // If initialize ultimately has the wrong shape, a second JSON-RPC POST probes | 33 | // One `fetch` (occasionally two), no MCP-SDK session state, no OAuth |
| 34 | // `server/discover` using the 2026-07-28 envelope and protocol header. This | 34 | // round-trip, no DCR — every non-MCP endpoint exits here. |
| 35 | // catches modern-only servers that reject initialize with a non-JSON-RPC | ||
| 36 | // response. Authentication outcomes remain terminal because they do not vary | ||
| 37 | // by transport era. | ||
| 38 | // | ||
| 39 | // One primary request (occasionally plus legacy GET and modern discover), no | ||
| 40 | // MCP-SDK session state, no OAuth round-trip, no DCR. | ||
| 41 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 42 | 36 | ||
| 43 | import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; | 37 | import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; |
| @@ -54,21 +48,12 @@ const INITIALIZE_BODY = JSON.stringify({ · 10 unmodified lines | |||
| 54 | id: 1, | 48 | id: 1, |
| 55 | method: "initialize", | 49 | method: "initialize", |
| 56 | params: { | 50 | params: { |
| 57 | protocolVersion: "2025-11-25", | 51 | protocolVersion: "2025-06-18", |
| 58 | capabilities: {}, | 52 | capabilities: {}, |
| 59 | clientInfo: { name: "executor-probe", version: "0" }, | 53 | clientInfo: { name: "executor-probe", version: "0" }, |
| 60 | }, | 54 | }, |
| 61 | }); | 55 | }); |
| 62 | 56 | ||
| 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 | /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s | 57 | /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s |
| 73 | * `Response.headers` already lower-cases, but we normalise explicitly | 58 | * `Response.headers` already lower-cases, but we normalise explicitly |
| 74 | * to stay robust against test mocks that construct `Headers` loosely. */ | 59 | * to stay robust against test mocks that construct `Headers` loosely. */ |
| @@ -400,9 +385,10 @@ export const probeMcpEndpointShape = ( · 325 unmodified lines | |||
| 400 | .execute(postRequest) | 385 | .execute(postRequest) |
| 401 | .pipe(Effect.timeout(Duration.millis(timeoutMs))); | 386 | .pipe(Effect.timeout(Duration.millis(timeoutMs))); |
| 402 | 387 | ||
| 403 | let initializeResult = yield* classify(postResponse, "POST"); | 388 | const postResult = yield* classify(postResponse, "POST"); |
| 389 | if (postResult) return postResult; | ||
| 404 | 390 | ||
| 405 | if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) { | 391 | if ([404, 405, 406, 415].includes(postResponse.status)) { |
| 406 | let getRequest = HttpClientRequest.get(url.toString()).pipe( | 392 | let getRequest = HttpClientRequest.get(url.toString()).pipe( |
| 407 | HttpClientRequest.setHeader("accept", "text/event-stream"), | 393 | HttpClientRequest.setHeader("accept", "text/event-stream"), |
| 408 | ); | 394 | ); |
| @@ -412,42 +398,15 @@ export const probeMcpEndpointShape = ( · 3 unmodified lines | |||
| 412 | const getResponse = yield* client | 398 | const getResponse = yield* client |
| 413 | .execute(getRequest) | 399 | .execute(getRequest) |
| 414 | .pipe(Effect.timeout(Duration.millis(timeoutMs))); | 400 | .pipe(Effect.timeout(Duration.millis(timeoutMs))); |
| 415 | initializeResult = yield* classify(getResponse, "GET"); | 401 | const getResult = yield* classify(getResponse, "GET"); |
| 402 | if (getResult) return getResult; | ||
| 416 | } | 403 | } |
| 417 | 404 | ||
| 418 | initializeResult ??= { | 405 | return { |
| 419 | kind: "not-mcp", | 406 | kind: "not-mcp", |
| 420 | category: "wrong-shape", | 407 | category: "wrong-shape", |
| 421 | reason: `unexpected status ${postResponse.status} for initialize`, | 408 | reason: `unexpected status ${postResponse.status} for initialize`, |
| 422 | } as const; | 409 | } as const; |
| 423 | |||
| 424 | if (initializeResult.kind !== "not-mcp" || initializeResult.category !== "wrong-shape") { | ||
| 425 | return initializeResult; | ||
| 426 | } | ||
| 427 | |||
| 428 | let discoverRequest = HttpClientRequest.post(url.toString()).pipe( | ||
| 429 | HttpClientRequest.setHeader("content-type", "application/json"), | ||
| 430 | HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), | ||
| 431 | HttpClientRequest.bodyText(DISCOVER_BODY, "application/json"), | ||
| 432 | ); | ||
| 433 | for (const [name, value] of Object.entries(options.headers ?? {})) { | ||
| 434 | discoverRequest = HttpClientRequest.setHeader(discoverRequest, name, value); | ||
| 435 | } | ||
| 436 | discoverRequest = HttpClientRequest.setHeader( | ||
| 437 | discoverRequest, | ||
| 438 | "MCP-Protocol-Version", | ||
| 439 | "2026-07-28", | ||
| 440 | ); | ||
| 441 | |||
| 442 | // The endpoint already answered the primary probe, so a transport | ||
| 443 | // failure on this secondary request must not overwrite that verdict | ||
| 444 | // with "unreachable" — keep the initialize classification instead. | ||
| 445 | const discoverResult = yield* client.execute(discoverRequest).pipe( | ||
| 446 | Effect.timeout(Duration.millis(timeoutMs)), | ||
| 447 | Effect.flatMap((discoverResponse) => classify(discoverResponse, "POST")), | ||
| 448 | Effect.catch(() => Effect.succeed(null)), | ||
| 449 | ); | ||
| 450 | return discoverResult ?? initializeResult; | ||
| 451 | }).pipe( | 410 | }).pipe( |
| 452 | Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer), | 411 | Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer), |
| 453 | Effect.mapError( | 412 | Effect.mapError( |
| @@ -3,9 +3,8 @@ · 2 unmodified lines | |||
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | // | 4 | // |
| 5 | // Kept in its own module so `connection.ts` never imports it eagerly at | 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 | 6 | // module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in |
| 7 | // evaluates Node-only process/stream imports and `cross-spawn` (which loads | 7 | // `node:child_process` at evaluation time; under `@cloudflare/vitest-pool-workers` |
| 8 | // `node:child_process`); under `@cloudflare/vitest-pool-workers` | ||
| 9 | // that crashes workerd at module instantiation with SIGSEGV (prod bundles | 8 | // that crashes workerd at module instantiation with SIGSEGV (prod bundles |
| 10 | // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not). | 9 | // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not). |
| 11 | // | 10 | // |
| @@ -14,7 +13,7 @@ · 2 unmodified lines | |||
| 14 | // the import and therefore never touch `node:child_process`. | 13 | // the import and therefore never touch `node:child_process`. |
| 15 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
| 16 | 15 | ||
| 17 | import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; | 16 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 18 | 17 | ||
| 19 | export type StdioTransportConfig = { | 18 | export type StdioTransportConfig = { |
| 20 | readonly command: string; | 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 | import { expect, layer } from "@effect/vitest"; | 1 | import { expect, layer } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 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 | import { OAuthTestServer } from "@executor-js/sdk/testing"; | 5 | import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| 5 | 6 | ||
| 6 | import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; | 7 | import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; |
| @@ -15,10 +16,7 @@ const createGreetingMcpServer = () => · 8 unmodified lines | |||
| 15 | }); | 16 | }); |
| 16 | 17 | ||
| 17 | const makeClient = (endpoint: string, accessToken: string) => { | 18 | const makeClient = (endpoint: string, accessToken: string) => { |
| 18 | const client = new Client( | 19 | const client = new Client({ name: "executor-test-client", version: "1.0.0" }); |
| 19 | { name: "executor-test-client", version: "1.0.0" }, | ||
| 20 | { versionNegotiation: { mode: "auto" } }, | ||
| 21 | ); | ||
| 22 | const transport = new StreamableHTTPClientTransport(new URL(endpoint), { | 20 | const transport = new StreamableHTTPClientTransport(new URL(endpoint), { |
| 23 | requestInit: { | 21 | requestInit: { |
| 24 | headers: { authorization: `Bearer ${accessToken}` }, | 22 | headers: { authorization: `Bearer ${accessToken}` }, |
| @@ -31,20 +31,6 @@ export type McpRemoteTransport = typeof McpRemoteTransport.Type; · 30 unmodified lines | |||
| 31 | export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); | 31 | export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); |
| 32 | export type McpTransport = typeof McpTransport.Type; | 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 | // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) | 35 | // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) |
| 50 | // plus MCP's own oauth variant. An integration declares zero or more methods, | 36 | // plus MCP's own oauth variant. An integration declares zero or more methods, |
| @@ -222,9 +208,6 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 171 unmodified lines | |||
| 222 | env: Schema.optional(StringMap), | 208 | env: Schema.optional(StringMap), |
| 223 | /** Working directory */ | 209 | /** Working directory */ |
| 224 | cwd: Schema.optional(Schema.String), | 210 | cwd: Schema.optional(Schema.String), |
| 225 | /** Protocol negotiation at connect. Absent means `legacy` (see | ||
| 226 | * `McpStdioVersionNegotiation` for why that stays the default). */ | ||
| 227 | versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | ||
| 228 | /** Declared auth methods — a single `stdio_env` method naming the secret env | 211 | /** Declared auth methods — a single `stdio_env` method naming the secret env |
| 229 | * vars, or `none`. A connection's `template` picks one by slug, exactly as | 212 | * vars, or `none`. A connection's `template` picks one by slug, exactly as |
| 230 | * for remote servers. Optional so pre-revamp stdio configs (which had no | 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 | import * as http from "node:http"; | 2 | import * as http from "node:http"; |
| 3 | // Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients. | 3 | // Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients. |
| 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 6 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; | ||
| 7 | import { OAuthTestServer } from "@executor-js/sdk/testing"; | 6 | import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| 8 | import z from "zod"; | 7 | import z from "zod"; |
| 9 | 8 | ||
| @@ -59,22 +58,6 @@ const writeText = (response: http.ServerResponse, status: number, body: string) · 49 unmodified lines | |||
| 59 | response.end(body); | 58 | response.end(body); |
| 60 | }; | 59 | }; |
| 61 | 60 | ||
| 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 | const isMcpPath = (url: string, path: string): boolean => { | 61 | const isMcpPath = (url: string, path: string): boolean => { |
| 79 | const parsed = new URL(url, "http://executor.test"); | 62 | const parsed = new URL(url, "http://executor.test"); |
| 80 | return parsed.pathname === path; | 63 | return parsed.pathname === path; |
| @@ -175,24 +158,6 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 94 unmodified lines | |||
| 175 | return; | 158 | return; |
| 176 | } | 159 | } |
| 177 | 160 | ||
| 178 | // Mirror the real v1 transport's stateful contract: only an | ||
| 179 | // `initialize` POST opens a session; any other sessionless POST | ||
| 180 | // (e.g. a v2 client's `server/discover` era probe) is rejected | ||
| 181 | // with 400 + a JSON-RPC error and no session is minted. | ||
| 182 | let parsedBody: unknown; | ||
| 183 | if (request.method === "POST") { | ||
| 184 | const body = yield* readRequestBody(request); | ||
| 185 | parsedBody = Option.getOrUndefined(decodeJsonBody(body)); | ||
| 186 | if (!isInitializeRequest(parsedBody)) { | ||
| 187 | writeJson(response, 400, { | ||
| 188 | jsonrpc: "2.0", | ||
| 189 | error: { code: -32000, message: "Bad Request: Server not initialized" }, | ||
| 190 | id: null, | ||
| 191 | }); | ||
| 192 | return; | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | const transport = new StreamableHTTPServerTransport({ | 161 | const transport = new StreamableHTTPServerTransport({ |
| 197 | sessionIdGenerator: () => crypto.randomUUID(), | 162 | sessionIdGenerator: () => crypto.randomUUID(), |
| 198 | onsessioninitialized: (sid) => { | 163 | onsessioninitialized: (sid) => { |
| @@ -208,7 +173,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 9 unmodified lines | |||
| 208 | catch: (cause) => new McpTestServerError({ cause }), | 173 | catch: (cause) => new McpTestServerError({ cause }), |
| 209 | }); | 174 | }); |
| 210 | yield* Effect.tryPromise({ | 175 | yield* Effect.tryPromise({ |
| 211 | try: () => transport.handleRequest(request, response, parsedBody), | 176 | try: () => transport.handleRequest(request, response), |
| 212 | catch: (cause) => new McpTestServerError({ cause }), | 177 | catch: (cause) => new McpTestServerError({ cause }), |
| 213 | }); | 178 | }); |
| 214 | }).pipe( | 179 | }).pipe( |
| @@ -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. | ||