diff --git a/.changeset/artifact-delete-navigation.md b/.changeset/artifact-delete-navigation.md new file mode 100644 index 0000000000..aa9ba333ad --- /dev/null +++ b/.changeset/artifact-delete-navigation.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Prevent deleted artifacts from briefly reappearing after returning to the artifact gallery. diff --git a/.changeset/artifact-external-links.md b/.changeset/artifact-external-links.md new file mode 100644 index 0000000000..68cf2257a5 --- /dev/null +++ b/.changeset/artifact-external-links.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +**Fix: links in generated artifacts (``) did nothing when clicked.** The sandbox iframe deliberately has no `allow-popups`, so the browser blocked the new browsing context and the click went nowhere. A trusted user click is now relayed across the frame boundary to the host's `openLink` capability — guarded by a per-render nonce so generated code cannot forge or observe it — and the host opens only `http`/`https` URLs. diff --git a/.changeset/artifact-hyphenated-tool-paths.md b/.changeset/artifact-hyphenated-tool-paths.md new file mode 100644 index 0000000000..c659cf6787 --- /dev/null +++ b/.changeset/artifact-hyphenated-tool-paths.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Render artifacts that call integrations or tools with hyphenated slugs. diff --git a/.changeset/artifact-source-text.md b/.changeset/artifact-source-text.md new file mode 100644 index 0000000000..b87f7d8bba --- /dev/null +++ b/.changeset/artifact-source-text.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +**Fix: `show-artifact` now returns the saved component source to MCP clients that cannot render Apps.** Agents can read the current source and make targeted edits instead of receiving only a link to the artifact. diff --git a/.changeset/cimd-refresh-token.md b/.changeset/cimd-refresh-token.md new file mode 100644 index 0000000000..22408ee8d1 --- /dev/null +++ b/.changeset/cimd-refresh-token.md @@ -0,0 +1,10 @@ +--- +"@executor-js/api": patch +--- + +Advertise refresh-token support in OAuth client ID metadata documents. + +OAuth providers may reject the `offline_access` scope when the client's +metadata declares only the authorization-code grant. Hosted and local client +metadata now declare both `authorization_code` and `refresh_token`, matching +Executor's dynamic client registration behavior. diff --git a/.changeset/cli-approval-public-origin.md b/.changeset/cli-approval-public-origin.md new file mode 100644 index 0000000000..ebe473e9c0 --- /dev/null +++ b/.changeset/cli-approval-public-origin.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Pin CLI browser approval links to `EXECUTOR_WEB_BASE_URL` so a TLS reverse proxy no longer returns an unreachable `http://` URL. diff --git a/.changeset/completed-mcp-tool-name.md b/.changeset/completed-mcp-tool-name.md new file mode 100644 index 0000000000..1f97e79c55 --- /dev/null +++ b/.changeset/completed-mcp-tool-name.md @@ -0,0 +1,5 @@ +--- +"@executor-js/execution": patch +--- + +Completed MCP execute results now include `toolName` when a script successfully uses exactly one connected tool. Executions that use distinct tools remain unlabeled, and internal call provenance is not exposed in the MCP response. diff --git a/.changeset/computer-use-remembered-approvals.md b/.changeset/computer-use-remembered-approvals.md new file mode 100644 index 0000000000..50ab5b6d7e --- /dev/null +++ b/.changeset/computer-use-remembered-approvals.md @@ -0,0 +1,14 @@ +--- +"@executor-js/sdk": patch +"@executor-js/execution": patch +"@executor-js/plugin-mcp": patch +"@executor-js/api": patch +"@executor-js/react": patch +"@executor-js/host-mcp": patch +"@executor-js/cloudflare": patch +"executor": patch +--- + +Carry an approval's persistence choice through elicitation, so Codex Computer Use stops asking to use the same app on every call. + +Computer Use offers `persist: ["session", "always"]` in the prompt's terms and remembers the app only when the answer names one. Executor dropped the offer on the way in (the terms projection kept strings only) and the choice on the way out (every adapter rebuilt the reply from `action` and `content`), so each accept was one-time. `ElicitationResponse` now has `meta.persist`; the MCP plugin, the app-server bridge, and the MCP host pass it through; the model-mode `resume` tool and the browser approval page let the approver pick from the offered scopes. Nothing is chosen automatically: a bare accept still approves once. diff --git a/.changeset/describe-tool-annotations.md b/.changeset/describe-tool-annotations.md new file mode 100644 index 0000000000..127b8f38af --- /dev/null +++ b/.changeset/describe-tool-annotations.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Return a tool's declared annotations from `tools.schema` and `describe.tool`. Code inside `execute` can now read `requiresApproval`, `approvalDescription` and `mayElicit` without parsing the tool's prose description. diff --git a/.changeset/great-hoops-repeat.md b/.changeset/great-hoops-repeat.md new file mode 100644 index 0000000000..9943a92068 --- /dev/null +++ b/.changeset/great-hoops-repeat.md @@ -0,0 +1,9 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Fetch Google Analytics Data (`analyticsdata`) Discovery from the service's own +host. The central directory does not list the GA4 Data API, so the canonical +`https://www.googleapis.com/discovery/v1/apis/analyticsdata/v1beta/rest` answers +404 and the source fails to import. Same treatment `forms`, `keep` and +`photospicker` already get. diff --git a/.changeset/hubspot-workspace-optional-scopes.md b/.changeset/hubspot-workspace-optional-scopes.md new file mode 100644 index 0000000000..71af545f24 --- /dev/null +++ b/.changeset/hubspot-workspace-optional-scopes.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Send HubSpot optional permissions in `optional_scope` for workspace OAuth clients so accounts can connect without optional product features. diff --git a/.changeset/mcp-elicitation-active-deadline.md b/.changeset/mcp-elicitation-active-deadline.md new file mode 100644 index 0000000000..5e9da731ea --- /dev/null +++ b/.changeset/mcp-elicitation-active-deadline.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +Exclude time spent waiting for elicitation from the MCP tool invocation deadline. diff --git a/.changeset/mcp-passthrough-mode.md b/.changeset/mcp-passthrough-mode.md new file mode 100644 index 0000000000..9008960f31 --- /dev/null +++ b/.changeset/mcp-passthrough-mode.md @@ -0,0 +1,7 @@ +--- +"@executor-js/sdk": minor +"@executor-js/execution": minor +"executor": minor +--- + +Add a search and invoke MCP mode (`?mode=passthrough`, `executor mcp --mode passthrough`). Search returns bounded pages of matching tool IDs and input schemas. Invoke validates arguments and runs the selected tool, with native client approval and workspace blocks enforced. The MCP catalog stays at two tools regardless of integration count. diff --git a/.changeset/mcp-session-executor-leak.md b/.changeset/mcp-session-executor-leak.md new file mode 100644 index 0000000000..e8419ec356 --- /dev/null +++ b/.changeset/mcp-session-executor-leak.md @@ -0,0 +1,7 @@ +--- +"@executor-js/host-mcp": patch +"@executor-js/api": patch +"executor": patch +--- + +Shut down scoped executors and tool subprocess resources upon MCP session eviction and disposal in the in-process session store. diff --git a/.changeset/openapi-transport-unreachable.md b/.changeset/openapi-transport-unreachable.md new file mode 100644 index 0000000000..05623ff515 --- /dev/null +++ b/.changeset/openapi-transport-unreachable.md @@ -0,0 +1,6 @@ +--- +"executor": patch +"@executor-js/plugin-openapi": patch +--- + +OpenAPI tools that cannot reach the upstream server now return an `upstream_unreachable` error instead of `Internal tool error [id]`. The message names the integration and origin that could not be reached, `details` carries the sanitized `host` and errno-style `code` (`ECONNREFUSED`, `ENOTFOUND`, …), and the failure is logged with the same classification. diff --git a/.changeset/post-health-checks.md b/.changeset/post-health-checks.md new file mode 100644 index 0000000000..21fd2d0281 --- /dev/null +++ b/.changeset/post-health-checks.md @@ -0,0 +1,6 @@ +--- +"@executor-js/plugin-openapi": patch +"@executor-js/react": patch +--- + +Support POST health checks for APIs that expose reads through HTTP RPC. Warn that POST can change data, allow validated JSON request bodies, and display the reason when a configured probe cannot run. diff --git a/.changeset/reduced-motion.md b/.changeset/reduced-motion.md new file mode 100644 index 0000000000..0ce1a65ad0 --- /dev/null +++ b/.changeset/reduced-motion.md @@ -0,0 +1,10 @@ +--- +"@executor-js/react": patch +--- + +fix: honor prefers-reduced-motion in the shared stylesheet + +Adds a `prefers-reduced-motion: reduce` block to the global stylesheet that +caps transition/animation durations to 0.01ms and disables smooth scrolling, +so motion-sensitive users get a stable UI. The loading spinner renders +statically under reduced motion (its meaning is preserved via `role="status"`). diff --git a/.changeset/tidy-login-state.md b/.changeset/tidy-login-state.md new file mode 100644 index 0000000000..7cbf222e3f --- /dev/null +++ b/.changeset/tidy-login-state.md @@ -0,0 +1,17 @@ +--- +"@executor-js/cloud": patch +--- + +fix: make login CSRF state mandatory in the WorkOS callback + +The callback previously skipped its CSRF check whenever the redirect carried +no `state` value ("some WorkOS-initiated redirects don't include one"). That +bypass let an attacker complete their own OAuth round-trip and redirect a +victim's browser through the callback with the attacker's `code` and no +`state`, silently signing the victim into the attacker's account (login CSRF). + +The check is now unconditional: a callback without a state matching the +`wos-login-state` cookie set on `/login` is rejected with 400. This is a +breaking change for any client relying on the undocumented no-state entry +path; server-initiated flows that cannot carry state must be redesigned with +a signed nonce instead of re-adding the bypass. diff --git a/.changeset/vercel-mcp-refresh-token.md b/.changeset/vercel-mcp-refresh-token.md new file mode 100644 index 0000000000..dbcf4e5d2a --- /dev/null +++ b/.changeset/vercel-mcp-refresh-token.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Keep Vercel MCP connections renewable by requesting the provider's `offline_access` lifecycle scope during registration and authorization. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index fc2a9bf391..e41a723b89 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1363,6 +1363,7 @@ const mcpUrlForActiveLocalServer = (input: { readonly elicitationMode: "browser" | "model"; readonly artifacts: boolean; readonly searchTools: boolean; + readonly toolMode: "codemode" | "passthrough"; }): URL => { const url = new URL("/mcp", input.connection.origin); if (input.elicitationMode === "browser") { @@ -1378,6 +1379,10 @@ const mcpUrlForActiveLocalServer = (input: { if (input.searchTools) { url.searchParams.set("search_tools", "true"); } + // Passthrough is the non-default surface; only it is spelled out. + if (input.toolMode === "passthrough") { + url.searchParams.set("mode", "passthrough"); + } return url; }; @@ -1394,6 +1399,7 @@ const runMcpHttpBridge = async (input: { readonly elicitationMode: "browser" | "model"; readonly artifacts: boolean; readonly searchTools: boolean; + readonly toolMode: "codemode" | "passthrough"; }): Promise => { const stdio = new StdioServerTransport(); const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); @@ -1403,6 +1409,7 @@ const runMcpHttpBridge = async (input: { elicitationMode: input.elicitationMode, artifacts: input.artifacts, searchTools: input.searchTools, + toolMode: input.toolMode, }), authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, ); @@ -1482,6 +1489,7 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model"; readonly artifacts: boolean; readonly searchTools: boolean; + readonly toolMode: "codemode" | "passthrough"; }) => Effect.gen(function* () { // `executor mcp` never owns the local database. If a local server is already @@ -1499,6 +1507,7 @@ const runStdioMcpSession = (input: { elicitationMode: input.elicitationMode, artifacts: input.artifacts, searchTools: input.searchTools, + toolMode: input.toolMode, }), ); return; @@ -1526,6 +1535,7 @@ const runStdioMcpSession = (input: { elicitationMode: input.elicitationMode, artifacts: input.artifacts, searchTools: input.searchTools, + toolMode: input.toolMode, }), ); }); @@ -2898,11 +2908,30 @@ const mcpCommand = Command.make( "Serve one search_ tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.", ), ), + toolMode: Options.choice("mode", ["codemode", "passthrough"] as const) + .pipe(Options.withDefault("codemode")) + .pipe( + Options.withDescription( + "codemode (default) serves the execute tool; passthrough serves search and invoke, with input schemas in search results and client approval for invoke.", + ), + ), }, - ({ scope, elicitationMode, noArtifacts, searchTools }) => + ({ scope, elicitationMode, noArtifacts, searchTools, toolMode }) => Effect.gen(function* () { applyScope(scope); - yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools }); + if (toolMode === "passthrough" && searchTools) { + return yield* Effect.fail( + new Error( + "--search-tools is a codemode option; passthrough already provides search. Drop --search-tools or --mode passthrough.", + ), + ); + } + yield* runStdioMcpSession({ + elicitationMode, + artifacts: !noArtifacts, + searchTools, + toolMode, + }); }), ).pipe(Command.withDescription("Start an MCP server over stdio")); diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 5c64be3c5c..2767a7ea49 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -115,6 +115,7 @@ const McpSessionExecutionParams = { const ResumeMcpExecutionBody = Schema.Struct({ action: Schema.Literals(["accept", "decline", "cancel"]), content: Schema.optional(Schema.Unknown), + persist: Schema.optional(Schema.String), }); const McpPausedExecutionResponse = Schema.Struct({ diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index ae91bd35a4..604aefab58 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -189,17 +189,17 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( const workos = yield* WorkOSClient; const users = yield* UserStoreService; const cookieState = request.cookies[STATE_COOKIE] ?? null; - // CSRF check is only enforced when the redirect carries a state - // value — some WorkOS-initiated redirects don't include one. - // When state is present, it MUST match the cookie we set on - // /login. - if (query.state !== undefined) { - if (!cookieState || !timingSafeEqual(cookieState, query.state)) { - return deleteResponseCookie( - HttpServerResponse.text("Invalid login state", { status: 400 }), - STATE_COOKIE, - ); - } + // CSRF is unconditional: every callback must carry a state that + // matches the cookie set on /login. There is no legitimate + // no-state entry path — omitting state previously allowed an + // attacker to complete their own OAuth round-trip and redirect a + // victim's browser through this callback, signing the victim into + // the attacker's account (login CSRF). + if (!cookieState || !timingSafeEqual(cookieState, query.state ?? "")) { + return deleteResponseCookie( + HttpServerResponse.text("Invalid login state", { status: 400 }), + STATE_COOKIE, + ); } const result = yield* workos.authenticateWithCode(query.code); @@ -210,7 +210,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( let sealedSession = result.sealedSession; // Resume where the SSR gate interrupted them. The state passed the - // CSRF check above whenever it's present, but it's still a + // CSRF check above, but it's still a // round-tripped value, so the returnTo inside it is re-validated like // any other untrusted path. const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; @@ -704,6 +704,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( { action: payload.action, content: payload.content as Record | undefined, + ...(payload.action === "accept" && payload.persist !== undefined + ? { meta: { persist: payload.persist } } + : {}), }, ), ); diff --git a/apps/cloud/src/auth/workos-callback-state.node.test.ts b/apps/cloud/src/auth/workos-callback-state.node.test.ts new file mode 100644 index 0000000000..3787f57b01 --- /dev/null +++ b/apps/cloud/src/auth/workos-callback-state.node.test.ts @@ -0,0 +1,166 @@ +// --------------------------------------------------------------------------- +// Focused tests — the WorkOS login callback's CSRF gate. +// +// The callback's CSRF check must be unconditional: no state ⇒ 400 before any +// WorkOS call; a replayed (already consumed) state ⇒ 400; a fresh state +// matching the cookie ⇒ 302 + session. +// +// Test seams follow repo conventions: @effect/vitest, Layer.succeed stubs +// (see org-selector-auth.node.test.ts), and HttpRouter.toWebHandler for the +// HTTP surface (see api.request-scope.node.test.ts). +// --------------------------------------------------------------------------- + +import { afterAll, describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpApi } from "effect/unstable/httpapi"; + +import { CloudAuthPublicHandlers } from "./handlers"; +import { CloudAuthPublicApi } from "./api"; +import { UserStoreService } from "./context"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { encodeLoginState } from "./login-state"; + +// The route under test serves under the `/api` prefix in the composed app; +// toWebHandler mounts the raw group, so paths here are relative to the group. +const SESSION_COOKIE = "wos-session"; +const STATE_COOKIE = "wos-login-state"; + +const STUB_USER_ID = "user_test"; +const STUB_SESSION = "sealed-session-stub"; +const STUB_ORG_ID = "org_test"; + +const stubWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_t, prop) => { + if (prop === "authenticateWithCode") { + return () => + Effect.succeed({ + user: { id: STUB_USER_ID, email: "u@test" }, + organizationId: STUB_ORG_ID, + sealedSession: STUB_SESSION, + }); + } + if (prop === "listUserMemberships") { + return () => Effect.succeed({ data: [] }); + } + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), +); + +const stubUsers = Layer.succeed(UserStoreService)({ + use: (_op, fn) => + Effect.promise(() => + fn({ + ensureAccount: async (id: string) => ({ id, createdAt: new Date() }), + getAccount: async (id: string) => ({ id, createdAt: new Date() }), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...org, + slug: org.id, + createdAt: new Date(), + }), + getOrganization: async (id: string) => ({ + id, + name: "Org " + id, + slug: id, + createdAt: new Date(), + }), + getOrganizationBySlug: async (slug: string) => ({ + id: slug, + name: slug, + slug, + createdAt: new Date(), + }), + deleteOrganizationCascade: async () => {}, + }), + ), +}); + +// Only the public group is under test; the session group (and its SessionAuth +// middleware, which needs a live DB) is out of scope — the callback route lives +// in CloudAuthPublicApi and requires no middleware. +const PublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi); + +const App = HttpApiBuilder.layer(PublicApi).pipe( + Layer.provide(CloudAuthPublicHandlers), + Layer.provide(stubWorkOS), + Layer.provide(stubUsers), + Layer.provide(HttpServer.layerServices), +); + +const app = HttpRouter.toWebHandler(App, { disableLogger: true }); +afterAll(() => app.dispose()); + +const run = (request: Request) => { + // beta.59: the handler type expects a context argument; this layer stack + // needs none at runtime — pass undefined like the api.request-scope tests. + return app.handler(request, undefined as never); +}; + +const callbackUrl = (state?: string, code = "code_1") => + `https://executor.test/auth/callback${state ? `?state=${encodeURIComponent(state)}` : ""}${state ? "&" : "?"}code=${code}`; + +describe("workos callback · CSRF state hardening", () => { + it("rejects a callback with NO state (the former bypass) before any WorkOS call", async () => { + const res = await run(new Request(callbackUrl(undefined), { redirect: "manual" })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Invalid login state"); + expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE); + }); + + it("rejects missing state even when the browser has a login cookie", async () => { + const res = await run( + new Request(callbackUrl(undefined), { + headers: { cookie: `${STATE_COOKIE}=victim-login-state` }, + redirect: "manual", + }), + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe("Invalid login state"); + expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE); + }); + + it("rejects a state that does not match the login cookie", async () => { + const res = await run( + new Request(callbackUrl("attacker-controlled-state"), { + headers: { cookie: `${STATE_COOKIE}=victim-login-state` }, + redirect: "manual", + }), + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Invalid login state"); + }); + + it("accepts a fresh state matching the cookie and issues a session (302 + cookie)", async () => { + // /login sets the cookie; simulate its value for this callback. + const state = encodeLoginState({ nonce: "nonce-123", returnTo: "/" }); + const res = await run( + new Request(callbackUrl(state), { + headers: { cookie: `${STATE_COOKIE}=${state}` }, + redirect: "manual", + }), + ); + expect(res.status).toBe(302); + expect(res.headers.get("set-cookie") ?? "").toContain(SESSION_COOKIE); + }); + + it("rejects a replayed state (single-use contract preserved downstream)", async () => { + // Replay of a state whose cookie is gone (already consumed by the login + // round-trip) must fail closed. + const state = encodeLoginState({ nonce: "nonce-replay", returnTo: "/" }); + const first = await run( + new Request(callbackUrl(state), { + headers: { cookie: `${STATE_COOKIE}=${state}` }, + redirect: "manual", + }), + ); + expect(first.status).toBe(302); + + // Second callback: same state, no cookie (session-store consumed it). + const replay = await run(new Request(callbackUrl(state), { redirect: "manual" })); + expect(replay.status).toBe(400); + }); +}); diff --git a/apps/cloud/src/engine/first-party-oauth-clients.ts b/apps/cloud/src/engine/first-party-oauth-clients.ts index 9c646e5d58..aa1359c161 100644 --- a/apps/cloud/src/engine/first-party-oauth-clients.ts +++ b/apps/cloud/src/engine/first-party-oauth-clients.ts @@ -6,6 +6,7 @@ import { } from "@executor-js/plugin-openapi/providers/microsoft"; import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; +import { HUBSPOT_OPTIONAL_SCOPES } from "@executor-js/sdk/host-internal"; import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing"; import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; @@ -152,12 +153,6 @@ const HUBSPOT_REQUIRED_SCOPES = [ "timeline", ] as const; -const HUBSPOT_OPTIONAL_SCOPES = [ - "content", - "crm.objects.custom.read", - "crm.schemas.custom.read", -] as const; - const MICROSOFT_SCOPES = [ "User.Read", "Calendars.ReadWrite", diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 05c2107ed0..3d94d3e9cb 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -16,6 +16,7 @@ import { readArtifactsEnabled, readElicitationMode, readSearchToolsEnabled, + readToolMode, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; @@ -189,6 +190,7 @@ const propsForPrincipal = ( elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), searchToolsEnabled: readSearchToolsEnabled(request), + toolMode: readToolMode(request), resource, webOrigin: new URL(request.url).origin, }, @@ -254,14 +256,19 @@ export const makeCloudMcpAgentHandler = () => { }); } + const resource = resourceFromPath(request); + if (sessionId) { let owner: "ok" | "not_found" | "forbidden" | "terminated"; // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a Durable Object stub RPC rejects with a plain platform Error, never a typed failure try { - owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }); + owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner( + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ); } catch (error) { // The sibling stub touchpoints in this handler are both guarded — the // `_cf_scheduleDestroy` call above with `Effect.ignore`, the @@ -290,7 +297,6 @@ export const makeCloudMcpAgentHandler = () => { } } - const resource = resourceFromPath(request); const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; const forwarded = withOrgWriteAccess( diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad76343..584b65ee0e 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 2646a785a0..701166ece7 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -384,6 +384,8 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase = { enterprise: "Custom enterprise agreement", }; +// Marker appended to the return URL so the page knows, on return, where it just +// came back from. `added`: the hosted card form (setup session) — the card only +// lands once the provider's webhook is processed, so wait for it. `managed`: +// the billing portal — the provider reads the default card live, so one +// refetch reflects whatever the user did there. +const CARD_RETURN_PARAM = "card"; +type CardReturn = "added" | "managed"; + +/** The card Autumn reports as the customer's default payment method (the + * Stripe PaymentMethod object, expanded via `payment_method`). */ +type CardOnFile = { + readonly id: string; + readonly brand: string; + readonly last4: string; + readonly expMonth: number; + readonly expYear: number; +}; + +const CARD_BRANDS: Record = { + visa: "Visa", + mastercard: "Mastercard", + amex: "American Express", + discover: "Discover", + diners: "Diners Club", + jcb: "JCB", + unionpay: "UnionPay", +}; + +const cardOnFile = (paymentMethod: unknown): CardOnFile | null => { + if (typeof paymentMethod !== "object" || paymentMethod === null) return null; + const pm = paymentMethod as { id?: unknown; card?: unknown }; + if (typeof pm.id !== "string" || typeof pm.card !== "object" || pm.card === null) return null; + const card = pm.card as { + brand?: unknown; + last4?: unknown; + exp_month?: unknown; + exp_year?: unknown; + expMonth?: unknown; + expYear?: unknown; + }; + const expMonth = card.expMonth ?? card.exp_month; + const expYear = card.expYear ?? card.exp_year; + if ( + typeof card.brand !== "string" || + typeof card.last4 !== "string" || + typeof expMonth !== "number" || + typeof expYear !== "number" + ) { + return null; + } + return { id: pm.id, brand: card.brand, last4: card.last4, expMonth, expYear }; +}; + +const cardBrandLabel = (brand: string): string => + CARD_BRANDS[brand] ?? (brand ? brand.charAt(0).toUpperCase() + brand.slice(1) : "Card"); + +/** + * Refresh the customer after returning from the hosted card form or the portal. + * + * Like checkout (see billing_.plans.tsx), the browser is redirected back from + * the card form before Stripe's webhook reaches Autumn, so the first fetch on + * return still shows no card. On detecting the `added` marker, poll until the + * default payment method differs from the one we came back with (or a + * timeout). Returns true while that reconciliation is in flight so the page can + * show the card as updating rather than the stale one. The `managed` marker + * (portal) has no race: a single refetch is enough. + */ +function useRefreshAfterCardUpdate(card: CardOnFile | null, refetch: () => void): boolean { + const [previousCardId, setPreviousCardId] = useState(undefined); + const cardRef = useRef(card); + cardRef.current = card; + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + const armedAtRef = useRef(0); + + // One-shot: consume the URL marker into state (see the plans page for why the + // poll keys off state rather than living in this effect). + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const returned = params.get(CARD_RETURN_PARAM) as CardReturn | null; + if (returned !== "added" && returned !== "managed") return; + params.delete(CARD_RETURN_PARAM); + const query = params.toString(); + window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`); + if (returned === "managed") { + refetchRef.current(); + return; + } + armedAtRef.current = Date.now(); + setPreviousCardId(cardRef.current?.id ?? null); + }, []); + + useEffect(() => { + if (previousCardId === undefined) return; + const reflected = () => (cardRef.current?.id ?? null) !== previousCardId; + + refetchRef.current(); + const interval = setInterval(() => { + if (reflected() || Date.now() - armedAtRef.current >= 20_000) { + clearInterval(interval); + setPreviousCardId(undefined); + return; + } + refetchRef.current(); + }, 1500); + return () => clearInterval(interval); + }, [previousCardId]); + + useEffect(() => { + if (previousCardId !== undefined && (card?.id ?? null) !== previousCardId) { + setPreviousCardId(undefined); + } + }, [previousCardId, card]); + + return previousCardId !== undefined; +} + function BillingPage() { - const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer(); + const { + data: customer, + openCustomerPortal, + setupPayment, + refetch: refetchCustomer, + isLoading: customerLoading, + } = useCustomer({ expand: ["payment_method"] }); const { data: plans, isLoading: plansLoading } = useListPlans(); + const card = cardOnFile(customer?.paymentMethod); + const cardUpdating = useRefreshAfterCardUpdate(card, refetchCustomer); + const [openingCardForm, setOpeningCardForm] = useState(false); if (customerLoading || plansLoading) { return ( @@ -112,6 +241,63 @@ function BillingPage() { {/* Divider */}
+ {/* Payment method */} +
+
+

Payment method

+

+ {cardUpdating ? ( + + + Updating card… + + ) : card ? ( + `${cardBrandLabel(card.brand)} ending in ${card.last4} · Expires ${String(card.expMonth).padStart(2, "0")}/${String(card.expYear).slice(-2)}` + ) : ( + "No card on file" + )} +

+
+ +
+ + {/* Divider */} +
+ {/* Usage */} {members && (
diff --git a/apps/cloud/src/routes/app/resume.$executionId.tsx b/apps/cloud/src/routes/app/resume.$executionId.tsx index a6129ca429..08a3dd2f24 100644 --- a/apps/cloud/src/routes/app/resume.$executionId.tsx +++ b/apps/cloud/src/routes/app/resume.$executionId.tsx @@ -37,13 +37,17 @@ function CloudMcpResumeApproval(props: { executionId: string; mcpSessionId: stri executionId: string, action: "accept" | "decline" | "cancel", content?: Record, + persist?: string, ) => doResume({ params: { mcpSessionId: props.mcpSessionId, executionId, }, - payload: action === "accept" ? { action, content: content ?? {} } : { action }, + payload: + action === "accept" + ? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) } + : { action }, }), [doResume, props.mcpSessionId], ); diff --git a/apps/docs/local/cli.mdx b/apps/docs/local/cli.mdx index f31a8b7855..68a380d826 100644 --- a/apps/docs/local/cli.mdx +++ b/apps/docs/local/cli.mdx @@ -43,6 +43,18 @@ executor web # open the web UI at http://127.0.0.1:4788 `executor install` registers Executor so it keeps running across restarts. For a throwaway foreground runtime instead, run `executor web --foreground`. +## Behind a TLS reverse proxy + +The daemon listens over HTTP on loopback. If a reverse proxy terminates TLS in +front of it, set `EXECUTOR_WEB_BASE_URL` to the public HTTPS origin so browser +approval links use that origin: + +```bash +EXECUTOR_WEB_BASE_URL=https://executor.example.test executor daemon run --foreground +``` + +Generated approval URLs take this value, not `X-Forwarded-Proto` or `Host`. + ## Connect an agent Add Executor to any MCP client (Claude Code, Cursor, OpenCode) with `npx add-mcp`. diff --git a/apps/docs/mcp-proxy.mdx b/apps/docs/mcp-proxy.mdx index c142f31b3b..b053205f91 100644 --- a/apps/docs/mcp-proxy.mdx +++ b/apps/docs/mcp-proxy.mdx @@ -56,3 +56,32 @@ Executor: Once a client is connected, every integration you add to Executor appears in that agent automatically. + +## Search and invoke mode + +Add `?mode=passthrough` to your MCP endpoint, or enable **Search and invoke** +in the Connect card. This mode exposes four tools: + +- `integrations`: list connected accounts with integration descriptions, account + labels, and their last recorded health. Results are paginated, with one item per + account. Health is not checked again by this call. +- `skills`: list this server's guides, or read `skills({ name: "search-invoke" })`. +- `search`: find tools and read their JSON input schemas. Use the exact + `integration`, `owner`, and `connection` fields from `integrations` to select an + account. +- `invoke`: call the exact tool ID from search with JSON arguments. + +For example, call `integrations({})`, then +`search({ query: "create issue", integration: "github", owner: "org", connection: "main" })` +using the account names returned by your server. Call `invoke` with the matching +result's `id` and arguments that follow its `inputSchema`. + +Both `integrations` and `search` return `items`, `total`, `hasMore`, and +`nextOffset`. To get another page, repeat the same query and filters with +`offset: nextOffset`. This pagination finds tools and accounts; each upstream API +has its own pagination for records. + +The tool list stays small as you add integrations. Input schemas are loaded only +for matching search results. Your client handles approval for `invoke`, and +workspace block policies still apply. This mode does not expose code execution or +artifact tools. diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 64ea2d4176..afd469b0c0 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -3,10 +3,10 @@ import { Effect, Predicate } from "effect"; import { McpAuthProvider, jsonRpcErrorBody, - defaultMcpResource, orgWriteAccessForPrincipal, withOrgWriteAccess, type AuthOutcome, + type McpResource, type Principal, } from "@executor-js/host-mcp"; import { @@ -14,6 +14,7 @@ import { readArtifactsEnabled, readElicitationMode, readSearchToolsEnabled, + readToolMode, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; @@ -22,6 +23,7 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; import { cloudflareAccessMcpAuth } from "./auth"; +import { mcpResourceFromPath } from "./resource"; import { McpSessionDO } from "./session-durable-object"; const corsPreflightResponse = (): Response => @@ -75,6 +77,7 @@ const authenticate = (request: Request, config: CloudflareConfig) => const propsForPrincipal = ( request: Request, principal: Principal, + resource: McpResource, ): Effect.Effect => Effect.gen(function* () { const propagation = yield* currentPropagationHeaders(request); @@ -86,10 +89,8 @@ const propsForPrincipal = ( elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), searchToolsEnabled: readSearchToolsEnabled(request), - // host-cloudflare only routes the bare `/mcp` endpoint to the Agent - // bridge (see worker.ts), so the session always serves the default - // resource. - resource: defaultMcpResource, + toolMode: readToolMode(request), + resource, webOrigin: new URL(request.url).origin, }, propagation, @@ -97,10 +98,12 @@ const propsForPrincipal = ( }); export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { - const serve = McpSessionDO.serve("/mcp", { + const serveOptions = { binding: "MCP_SESSION", transport: "streamable-http", - }); + } as const; + const serveDefault = McpSessionDO.serve("/mcp", serveOptions); + const serveToolkit = McpSessionDO.serve("/mcp/toolkits/:slug", serveOptions); return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { if (request.method === "OPTIONS") return corsPreflightResponse(); @@ -120,15 +123,23 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { return renderAuthError(auth, request, outcome); } + const resource = mcpResourceFromPath(new URL(request.url).pathname); + if (resource === null) { + return jsonRpcResponse(404, -32001, "MCP route not found"); + } + if (!sessionId && request.method === "DELETE") { return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); } if (sessionId) { - const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }); + const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner( + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ); if (owner === "not_found") { return jsonRpcResponse(404, -32001, "Session not found"); } @@ -142,7 +153,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } } - const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; const forwarded = withOrgWriteAccess( withVerifiedIdentityHeaders( @@ -151,10 +162,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }, - defaultMcpResource, + resource, ), orgWriteAccessForPrincipal(outcome.principal), ); - return serve.fetch(forwarded, env, ctx); + const target = resource.kind === "toolkit" ? serveToolkit : serveDefault; + return target.fetch(forwarded, env, ctx); }; }; diff --git a/apps/host-cloudflare/src/mcp/resource.test.ts b/apps/host-cloudflare/src/mcp/resource.test.ts new file mode 100644 index 0000000000..a29383aa0c --- /dev/null +++ b/apps/host-cloudflare/src/mcp/resource.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { mcpResourceFromPath } from "./resource"; + +describe("mcpResourceFromPath", () => { + it("classifies the default MCP path", () => { + expect(mcpResourceFromPath("/mcp")).toEqual({ kind: "default" }); + }); + + it("classifies a toolkit MCP path", () => { + expect(mcpResourceFromPath("/mcp/toolkits/calendar-tools")).toEqual({ + kind: "toolkit", + slug: "calendar-tools", + }); + }); + + it.each([ + "/", + "/mcp/", + "/mcp/toolkits", + "/mcp/toolkits/", + "/mcp//toolkits/calendar-tools", + "/mcp/toolkits//calendar-tools", + "/mcp/toolkits/calendar-tools/extra", + "/api/toolkits/calendar-tools", + ])("rejects the non-serving path %s", (pathname) => { + expect(mcpResourceFromPath(pathname)).toBeNull(); + }); +}); diff --git a/apps/host-cloudflare/src/mcp/resource.ts b/apps/host-cloudflare/src/mcp/resource.ts new file mode 100644 index 0000000000..193a3efc49 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/resource.ts @@ -0,0 +1,8 @@ +import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; + +export const mcpResourceFromPath = (pathname: string): McpResource | null => { + if (pathname === "/mcp") return defaultMcpResource; + + const toolkitMatch = /^\/mcp\/toolkits\/([^/]+)$/.exec(pathname); + return toolkitMatch?.[1] ? { kind: "toolkit", slug: toolkitMatch[1] } : null; +}; diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index ba4f2ff861..62d8685747 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -133,6 +133,7 @@ export class McpSessionDO extends McpAgentSessionDOBase { expect(toolNames).toContain("execute"); }, 60_000); + it("serves toolkit MCP sessions and rejects cross-resource session reuse", async () => { + const createToolkit = await worker.fetch("/api/toolkits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + owner: "org", + name: `Cloudflare Toolkit ${runId}`, + slug: `cloudflare-toolkit-${runId}`, + }), + }); + expect(createToolkit.status).toBe(200); + const toolkit = (await createToolkit.json()) as { id: string; slug: string }; + + const addConnection = await worker.fetch(`/api/toolkits/${toolkit.id}/connections`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ pattern: "executor.*" }), + }); + expect(addConnection.status).toBe(200); + + const accept = "application/json, text/event-stream"; + const toolkitPath = `/mcp/toolkits/${toolkit.slug}`; + const rpc = (path: string, sessionId: string | null, body: unknown) => + worker.fetch(path, { + method: "POST", + headers: { + "content-type": "application/json", + accept, + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify(body), + }); + + const init = await rpc(toolkitPath, null, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "toolkit-route-test", version: "1" }, + }, + }); + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + await rpc(toolkitPath, sessionId, { + jsonrpc: "2.0", + method: "notifications/initialized", + }); + + const list = await rpc(toolkitPath, sessionId, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + }); + expect(list.status).toBe(200); + const listed = await readMcpJson<{ + result?: { tools?: ReadonlyArray<{ name: string }> }; + }>(list); + expect(listed.result?.tools?.map((tool) => tool.name)).toContain("execute"); + + const reusedOnDefault = await rpc("/mcp", sessionId, { + jsonrpc: "2.0", + id: 3, + method: "tools/list", + }); + expect(reusedOnDefault.status).toBe(403); + + const reusedOnOtherToolkit = await rpc(`${toolkitPath}-other`, sessionId, { + jsonrpc: "2.0", + id: 4, + method: "tools/list", + }); + expect(reusedOnOtherToolkit.status).toBe(403); + for (const method of ["GET", "DELETE"]) { + const response = await worker.fetch("/mcp", { + method, + headers: { accept, "mcp-session-id": sessionId! }, + }); + expect(response.status).toBe(403); + } + const stillUsable = await rpc(toolkitPath, sessionId, { + jsonrpc: "2.0", + id: 5, + method: "tools/list", + }); + expect(stillUsable.status).toBe(200); + const deleted = await worker.fetch(toolkitPath, { + method: "DELETE", + headers: { accept, "mcp-session-id": sessionId! }, + }); + expect(deleted.status).toBe(204); + }, 60_000); + it("serves streamable HTTP GET only for initialized sessions", async () => { const missing = await worker.fetch("/mcp", { method: "GET", @@ -513,7 +609,9 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { }; }>(resume); expect(resumed.result?.structuredContent?.status).not.toBe("execution_not_found"); - expect(resumed.result?.structuredContent?.recovery).not.toBe("re_execute"); + expect(resumed.result?.structuredContent?.recovery, JSON.stringify(resumed.result)).not.toBe( + "re_execute", + ); expect(resumed.result?.isError).toBeFalsy(); expect(resumed.result?.structuredContent?.status).toBe("completed"); }, 60_000); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index ac9c1b30b7..b9964fac4a 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -4,6 +4,7 @@ import { missingCloudflareAccessVars, type CloudflareEnv, } from "./config"; +import { mcpResourceFromPath } from "./mcp/resource"; // The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported // at the Worker entry module scope for the runtime to find them. @@ -11,9 +12,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; // --------------------------------------------------------------------------- // The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web -// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs -// the Cloudflare `ExecutionContext` to pass authenticated session props into the -// hibernatable Durable Object bridge. +// handler. `/mcp` and `/mcp/toolkits/:slug` stay at this edge boundary because +// `McpAgent.serve()` needs the Cloudflare `ExecutionContext` to pass +// authenticated session props into the hibernatable Durable Object bridge. // --------------------------------------------------------------------------- let handlerPromise: Promise<{ @@ -48,7 +49,8 @@ export default { } const serve = await resolveHandler(env); - if (new URL(request.url).pathname === "/mcp") { + const resource = mcpResourceFromPath(new URL(request.url).pathname); + if (resource !== null) { return serve.mcp(request, env, ctx); } return serve.app(request); diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 2ef674c572..f3681e1217 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -123,8 +123,11 @@ export const createServerHandlers = async (token: string): Promise { if (resource.kind === "default") { return { @@ -132,6 +135,8 @@ export const createServerHandlers = async (token: string): Promise => { }; }; -const makeMcpFetch = (executor: Executor) => { +const makeMcpFetch = ( + executor: Executor, + options: { + readonly webBaseUrl?: string; + readonly extraHeaders?: HeadersInit; + } = {}, +) => { const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor(), }); - const mcp = createMcpRequestHandler({ engine }); + const mcp = createMcpRequestHandler( + options.webBaseUrl === undefined + ? { engine } + : { defaultConfig: { engine }, webBaseUrl: options.webBaseUrl }, + ); const fetchImpl: typeof globalThis.fetch = Object.assign( (input: RequestInfo | URL, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init); - const url = new URL(request.url); - if (url.pathname.startsWith("/mcp")) return mcp.handleRequest(request); + const headers = new Headers(request.headers); + if (options.extraHeaders) { + new Headers(options.extraHeaders).forEach((value, key) => headers.set(key, value)); + } + const forwarded = new Request(request, { headers }); + const url = new URL(forwarded.url); + if (url.pathname.startsWith("/mcp")) return mcp.handleRequest(forwarded); if (url.pathname.startsWith("/api/mcp-sessions/")) { - return mcp.handleApprovalRequest(request); + return mcp.handleApprovalRequest(forwarded); } return Promise.resolve(new Response("Not found", { status: 404 })); }, @@ -192,6 +207,11 @@ describe("local MCP browser approval resume", () => { expect(first.isError).toBeFalsy(); const firstApproval = readApproval(first.structuredContent); + expect(firstApproval.url.origin).toBe(TEST_BASE_URL); + expect(firstApproval.url.pathname).toBe( + `/resume/${encodeURIComponent(firstApproval.executionId)}`, + ); + expect(firstApproval.url.searchParams.get("mcp_session_id")).not.toBeNull(); const second = await approveInBrowserThenResume(fetch, mcpClient, firstApproval); const secondApproval = readApproval(second.structuredContent); @@ -228,6 +248,95 @@ describe("local MCP browser approval resume", () => { rmSync(tmpDir, { recursive: true, force: true }); } }, 10_000); + + it("uses EXECUTOR_WEB_BASE_URL for approval links when the request is internal HTTP", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "executor-local-browser-resume-origin-")); + const executor = await makeExecutor(tmpDir); + const { fetch, dispose } = makeMcpFetch(executor, { + webBaseUrl: "https://executor.example.test:8443/prefix?from-base=1", + extraHeaders: { + "x-forwarded-proto": "https", + "x-forwarded-host": "poisoned.example", + }, + }); + const mcpClient = new Client( + { name: "browser-resume-origin-test-client", version: "1.0.0" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport( + new URL("/mcp?elicitation_mode=browser", "http://127.0.0.1:4788"), + { fetch }, + ); + + await mcpClient.connect(transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test owns MCP transports, web handler, and executor lifecycle + try { + const paused = await mcpClient.callTool({ + name: "execute", + arguments: { + code: `return await tools.api.singleApproval({});`, + }, + }); + + expect(paused.isError).toBeFalsy(); + const approval = readApproval(paused.structuredContent); + expect(approval.url.origin).toBe("https://executor.example.test:8443"); + expect(approval.url.pathname).toBe(`/resume/${encodeURIComponent(approval.executionId)}`); + expect(approval.url.pathname).not.toContain("/prefix"); + expect(approval.url.searchParams.get("from-base")).toBeNull(); + expect(approval.url.searchParams.get("mcp_session_id")).not.toBeNull(); + expect(approval.url.host).not.toBe("poisoned.example"); + expect(approval.url.protocol).not.toBe("http:"); + } finally { + await mcpClient.close(); + await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => dispose()))); + await Effect.runPromise( + Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))), + ); + rmSync(tmpDir, { recursive: true, force: true }); + } + }, 10_000); + + it("falls back to the request origin when EXECUTOR_WEB_BASE_URL uses ephemeral port 0", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "executor-local-browser-resume-port0-")); + const executor = await makeExecutor(tmpDir); + const { fetch, dispose } = makeMcpFetch(executor, { + webBaseUrl: "http://127.0.0.1:0", + }); + const mcpClient = new Client( + { name: "browser-resume-port0-test-client", version: "1.0.0" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport( + new URL("/mcp?elicitation_mode=browser", "http://127.0.0.1:4788"), + { fetch }, + ); + + await mcpClient.connect(transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test owns MCP transports, web handler, and executor lifecycle + try { + const paused = await mcpClient.callTool({ + name: "execute", + arguments: { + code: `return await tools.api.singleApproval({});`, + }, + }); + + expect(paused.isError).toBeFalsy(); + const approval = readApproval(paused.structuredContent); + expect(approval.url.origin).toBe("http://127.0.0.1:4788"); + expect(approval.url.port).not.toBe("0"); + } finally { + await mcpClient.close(); + await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => dispose()))); + await Effect.runPromise( + Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))), + ); + rmSync(tmpDir, { recursive: true, force: true }); + } + }, 10_000); }); const approveInBrowserThenResume = async ( diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index caa5548d4a..8f6181bbb9 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -15,12 +15,13 @@ import { type ExecutorMcpServerConfig, } from "@executor-js/host-mcp/tool-server"; import { - approvalUrlForRequest, + buildResumeApprovalUrl, decodeResumeResponse, formatResumeAcknowledgement, readArtifactsEnabled, readElicitationMode, readSearchToolsEnabled, + readToolMode, } from "@executor-js/host-mcp/browser-approval"; import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store"; import { @@ -56,6 +57,14 @@ export interface LocalMcpRequestHandlerConfig { readonly createConfigForResource?: ( resource: McpResource, ) => Promise | LocalMcpServerConfig; + /** + * Pinned public origin for browser-approval URLs. When set (for example + * `EXECUTOR_WEB_BASE_URL` behind a TLS proxy) it is preferred over the + * request URL, whose scheme is the internal HTTP listener. Omit it on + * loopback so the request origin stays the approval link. Port 0 (an + * ephemeral bind placeholder) is treated as unset. + */ + readonly webBaseUrl?: string; } // Local serves these error bodies in-process; like the self-host store they are @@ -122,6 +131,14 @@ const normalizeHandlerConfig = ( input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); +// `--port 0` (e2e, some CLI boots) installs EXECUTOR_WEB_BASE_URL with port 0 +// before the OS assigns a listen port. That origin is not browser-reachable +// (Chrome ERR_UNSAFE_PORT), so approval URLs fall back to the request. +const resumeApprovalOrigin = (configured: string | undefined, requestUrl: string): string => { + if (configured === undefined || configured.length === 0) return requestUrl; + return new URL(configured).port === "0" ? requestUrl : configured; +}; + export const createMcpRequestHandler = ( input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, ): McpRequestHandler => { @@ -230,12 +247,17 @@ export const createMcpRequestHandler = ( browserApprovalStore: approvals.store, artifactsEnabled: readArtifactsEnabled(request), searchToolsEnabled: readSearchToolsEnabled(request), + mode: readToolMode(request), elicitationMode: elicitationMode === "browser" ? { mode: "browser" as const, approvalUrl: (executionId) => - approvalUrlForRequest(request, executionId, createdSessionId), + buildResumeApprovalUrl({ + origin: resumeApprovalOrigin(handlerConfig.webBaseUrl, request.url), + executionId, + sessionId: createdSessionId, + }), } : { mode: elicitationMode }, }), diff --git a/bun.lock b/bun.lock index 473028d7be..d79d6f4009 100644 --- a/bun.lock +++ b/bun.lock @@ -356,7 +356,7 @@ "version": "0.0.48", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.1", + "@executor-js/emulate": "^0.14.2", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -1779,7 +1779,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.14.1", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-MO72WgLgjyOJnWEiL8DrT6jAVs8C2VDz9oGUFkZ/x2GciEkiOkYG67u5/o+nqtPoxJTR06cqpkHjfrzCm1dqww=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/cloud/billing-payment-method-update.test.ts b/e2e/cloud/billing-payment-method-update.test.ts new file mode 100644 index 0000000000..1ee68ffbeb --- /dev/null +++ b/e2e/cloud/billing-payment-method-update.test.ts @@ -0,0 +1,130 @@ +// Cloud-only (billing, browser): an organization can add the card it is billed +// on and later change it from the billing page, and the page shows the current +// card WITHOUT a manual reload. +// +// The card lives at the billing provider, never in the app: the billing page +// reads the customer's default payment method (`payment_method` expand). Two +// journeys, because the provider treats them differently (verified against the +// live sandbox API): +// +// 1. No card yet — "Add card" opens a hosted setup session +// (`billing.setup_payment`). The browser is redirected back BEFORE the +// provider's webhook sets the default card, so the page tags its return +// URL, shows the card as updating, and refetches until it reflects. +// 2. A card on file — a setup session never REPLACES an existing default, so +// "Update card" opens the billing portal (`billing.open_customer_portal`) +// where the user adds a card and makes it the default. The provider reads +// the default live, so one refetch on return shows the new card. +// +// The emulator models both faithfully: completing the hosted setup form +// redirects back immediately but does NOT set the card until the webhook +// settles (autumn.settleSetup); the portal applies the card at once. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Autumn, Billing, Browser, Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; +import { visit } from "../src/surfaces/browser"; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +/** The org the bearer is scoped to — the Autumn customer id every billing call + * is made against — read from the JWT's public claims. */ +const orgIdOf = (bearer: string): string => { + const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as { + readonly org_id?: string; + }; + if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim"); + return claims.org_id; +}; + +scenario( + "Billing · adding and changing the card shows the current card without a reload", + { timeout: 120_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + const before = yield* autumn.paymentMethod(customerId); + expect(before, "a fresh org has no card on file").toBeNull(); + + yield* browser.session(identity, async ({ page, step }) => { + const paymentMethodRow = page + .getByText("Payment method", { exact: true }) + .locator("xpath=ancestor::div[contains(@class,'justify-between')][1]"); + + let sessionId = ""; + await step("Open the billing page and add a card", async () => { + // Billing requests are org-scoped via the URL slug header (see + // billing-trial-checkout-stale.test.ts for why we wait for the slug). + await visit(page, "/"); + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { + timeout: 30_000, + }); + const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]; + await visit(page, `/${slug}/billing`); + await paymentMethodRow.getByText("No card on file").waitFor(); + await paymentMethodRow.getByRole("button", { name: "Add card" }).click(); + // setupPayment() redirects the whole page to the hosted setup URL. + await page.waitForURL(/\/checkout\/setup\//, { timeout: 30_000 }); + sessionId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? ""; + expect(sessionId, "captured the setup session id").toMatch(/^seti_/); + }); + + await step("Save the card and return to the billing page", async () => { + await page.locator("input[name='card_number']").fill("4242 4242 4242 4242"); + await page.locator("input[name='exp']").fill("12/30"); + await page.locator("button.checkout-pay-btn").click(); + await page.waitForURL(/\/billing(\?|$)/, { timeout: 30_000 }); + // The webhook has NOT landed yet, but the page knows from the return + // marker that a card was just saved, so it shows the card as updating + // rather than "No card on file" (which would read as if nothing + // happened). This is the key user-facing guarantee. + await paymentMethodRow.getByText("Updating card").waitFor({ timeout: 10_000 }); + }); + + // The provider webhook reaches Autumn: the org's default card is set. + await Effect.runPromise(autumn.settleSetup(sessionId)); + + await step("The new card appears without a reload", async () => { + await paymentMethodRow.getByText("Visa ending in 4242").waitFor({ timeout: 15_000 }); + }); + + await step("Change the card in the billing portal", async () => { + await paymentMethodRow.getByRole("button", { name: "Update card" }).click(); + // openCustomerPortal() redirects the whole page to the hosted portal. + await page.waitForURL(/\/checkout\/portal\//, { timeout: 30_000 }); + await page.locator("input[name='card_number']").fill("5555 5555 5555 4444"); + await page.locator("input[name='exp']").fill("11/31"); + await page.locator("button.checkout-pay-btn").click(); + await page.getByText("4444").first().waitFor({ timeout: 10_000 }); + await page.getByRole("link", { name: /^Return to/ }).click(); + await page.waitForURL(/\/billing(\?|$)/, { timeout: 30_000 }); + }); + + await step("The billing page shows the card chosen in the portal", async () => { + await paymentMethodRow.getByText("Mastercard ending in 4444").waitFor({ timeout: 15_000 }); + expect( + await paymentMethodRow.getByText("Updating card").count(), + "no webhook wait for a portal change", + ).toBe(0); + }); + }); + + const after = yield* autumn.paymentMethod(customerId); + expect(after, "the billing provider holds the new card").toEqual({ + brand: "mastercard", + last4: "4444", + expMonth: 11, + expYear: 2031, + }); + }), +); diff --git a/e2e/cloud/login-csrf.test.ts b/e2e/cloud/login-csrf.test.ts new file mode 100644 index 0000000000..fbc237e8b3 --- /dev/null +++ b/e2e/cloud/login-csrf.test.ts @@ -0,0 +1,75 @@ +import { randomUUID } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +scenario( + "Login CSRF · state is required, bound to the browser, and consumed after login", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const email = `csrf-${randomUUID()}@e2e.test`; + yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { + const interceptCallback = async (): Promise => { + let callback: string | undefined; + // Pause the real provider response before its redirect reaches the app. + // Playwright does not route subsequent hops of a redirect chain. + await page.route("**/user_management/authorize/submit", async (route) => { + const response = await route.fetch({ maxRedirects: 0 }); + expect(response.status()).toBe(302); + callback = response.headers().location; + await route.fulfill({ + status: 200, + contentType: "text/plain", + body: "Authorization ready for callback validation", + }); + }); + await page.goto(new URL("/api/auth/login", target.baseUrl).toString()); + await page.getByPlaceholder("new-user@example.com").fill(email); + await page.getByRole("button", { name: /Continue/ }).click(); + await expect.poll(() => callback).toBeDefined(); + await page.unroute("**/user_management/authorize/submit"); + if (!callback) throw new Error("AuthKit did not return a callback"); + return callback; + }; + await step("Refuse a valid authorization code with no state", async () => { + const callback = new URL(await interceptCallback()); + callback.searchParams.delete("state"); + const response = await page.request.get(callback.toString(), { maxRedirects: 0 }); + expect(response.status()).toBe(400); + expect(await response.text()).toBe("Invalid login state"); + expect( + (await page.context().cookies()).some((cookie) => cookie.name === "wos-session"), + ).toBe(false); + }); + await step("Refuse a state from another login", async () => { + const callback = new URL(await interceptCallback()); + callback.searchParams.set("state", "another-browser-state"); + const response = await page.request.get(callback.toString(), { maxRedirects: 0 }); + expect(response.status()).toBe(400); + expect(await response.text()).toBe("Invalid login state"); + expect( + (await page.context().cookies()).some((cookie) => cookie.name === "wos-session"), + ).toBe(false); + }); + await step("Complete a fresh login, then reject the same callback again", async () => { + const callback = await interceptCallback(); + await page.goto(callback); + await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); + const cookies = await page.context().cookies(); + expect(cookies.some((cookie) => cookie.name === "wos-session")).toBe(true); + expect(cookies.some((cookie) => cookie.name === "wos-login-state")).toBe(false); + const me = await page.request.get(new URL("/api/auth/me", target.baseUrl).toString()); + expect(me.status()).toBe(200); + expect(await me.json()).toMatchObject({ user: { email } }); + const replay = await page.request.get(callback, { maxRedirects: 0 }); + expect(replay.status()).toBe(400); + expect(await replay.text()).toBe("Invalid login state"); + }); + }); + }), +); diff --git a/e2e/cloud/mcp-session-cap-eviction.test.ts b/e2e/cloud/mcp-session-cap-eviction.test.ts index 8724edd5aa..8103eef5cd 100644 --- a/e2e/cloud/mcp-session-cap-eviction.test.ts +++ b/e2e/cloud/mcp-session-cap-eviction.test.ts @@ -19,8 +19,8 @@ // the whole boot (see that file for the value and its headroom story), so // this test can cross it with a bounded number of real sessions instead of // registering the production default of 32. -import { expect } from "@effect/vitest"; -import { Effect, Schedule } from "effect"; +import { expect, it } from "@effect/vitest"; +import { Effect, Option, Schedule, Schema } from "effect"; import { scenario } from "../src/scenario"; import { Mcp, Target, Telemetry } from "../src/services"; @@ -52,6 +52,68 @@ const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: str body: JSON.stringify(body), }); +const decodeRestartEnvelope = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Null, + error: Schema.Struct({ + code: Schema.Literal(-32001), + message: Schema.Literal("MCP session is restarting, please retry"), + }), + }), + ), +); + +const isRestartResponse = (status: number, body: string): boolean => + status === 503 && Option.isSome(decodeRestartEnvelope(body)); + +it.each([ + [ + 503, + { + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "MCP session is restarting, please retry" }, + }, + true, + ], + [ + 404, + { + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "MCP session is restarting, please retry" }, + }, + false, + ], + [ + 503, + { + jsonrpc: "2.0", + id: null, + error: { code: -32603, message: "MCP session is restarting, please retry" }, + }, + false, + ], + [ + 503, + { + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "MCP session is restarting unexpectedly" }, + }, + false, + ], + [503, { error: "MCP session is restarting, please retry" }, false], +] as const)("only retries the documented restart envelope (%s, %j)", (status, body, retry) => { + expect(isRestartResponse(status, JSON.stringify(body))).toBe(retry); +}); + +it("does not retry malformed restart responses", () => { + expect(isRestartResponse(503, "MCP session is restarting, please retry")).toBe(false); +}); + /** * Opens one fresh MCP session under an already-minted bearer. `initialize` * without an existing `mcp-session-id` always mints a new session, the same @@ -73,21 +135,42 @@ const openSession = async ( label: string, recordSession: (sessionId: string) => void, ): Promise => { - const initialized = await postJson(mcpUrl, bearer, { - jsonrpc: "2.0" as const, - id: "initialize", - method: "initialize", - params: { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" }, - }, - }); - const sessionId = initialized.headers.get("mcp-session-id"); - if (!sessionId) { + // The platform can reset a session Durable Object while its initialize + // is in flight, and the server answers that with the documented restart + // envelope (503, -32001, "MCP session is restarting, please retry") — the + // same contract a streamable-http client follows: same request, after the + // advertised delay. Treat it as transient here instead of failing the + // scenario on a retryable platform blip. + const RESTART_ATTEMPTS = 8; + const RESTART_DELAY_MS = 2_000; // The host advertises Retry-After: 2. + let minted: { readonly response: Response; readonly sessionId: string } | undefined; + for (let attempt = 0; attempt < RESTART_ATTEMPTS; attempt += 1) { + const response = await postJson(mcpUrl, bearer, { + jsonrpc: "2.0" as const, + id: "initialize", + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" }, + }, + }); + const candidate = response.headers.get("mcp-session-id"); + if (candidate !== null && candidate.length > 0) { + minted = { response, sessionId: candidate }; + break; + } + const body = await response.text(); + const isRestart = isRestartResponse(response.status, body); + if (!isRestart) break; + if (attempt === RESTART_ATTEMPTS - 1) break; + await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS)); + } + if (!minted) { // oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition. throw new Error(`openSession (${label}): no mcp-session-id header`); } + const { response: initialized, sessionId } = minted; // Recorded the moment the id exists — BEFORE the body read and status // assertion below, either of which can throw with the session already live // on the server. The cleanup finalizer needs the id on every one of those diff --git a/e2e/cloud/org-api-keys-console.test.ts b/e2e/cloud/org-api-keys-console.test.ts index 828dcb9afb..f4b34dafb8 100644 --- a/e2e/cloud/org-api-keys-console.test.ts +++ b/e2e/cloud/org-api-keys-console.test.ts @@ -110,7 +110,9 @@ scenario( .getByRole("heading", { name: "Revoke organization key" }) .waitFor({ state: "hidden", timeout: 30_000 }); - // The revoked value no longer authenticates. + // The dialog closes when revocation starts. Wait for the confirmed + // provider mutation before asserting the key no longer authenticates. + await page.getByText("Revoked e2e backend reader", { exact: true }).waitFor(); const after = await fetch(new URL("/api/admin/users", target.baseUrl), { headers: { authorization: `Bearer ${mintedValue}` }, }); diff --git a/e2e/cloud/passthrough-opencode-codemode.test.ts b/e2e/cloud/passthrough-opencode-codemode.test.ts new file mode 100644 index 0000000000..fc27cbdc5b --- /dev/null +++ b/e2e/cloud/passthrough-opencode-codemode.test.ts @@ -0,0 +1,327 @@ +// Real OpenCode codemode discovers and invokes tools through a fixed MCP tool surface. +// The server retains 10,200 tools; the replay model scripts discovery and a real upstream call. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { join } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { serveReplayBrain } from "../src/clients/replay-brain"; +import { scenario } from "../src/scenario"; +import { Api, Cli, OpenCode, RunDir, Target } from "../src/services"; +import { catalogApi, seedLargeCatalog } from "../scenarios/support/large-catalog"; + +const SERVER_NAME = "executor"; + +// 322 (the real Vercel fixture) + 12 × 823 synthetic = 10,198, plus the two +// callable operations below = exactly 10,200 searchable tools. +const SYNTHETIC_INTEGRATIONS = 12; +const OPS_PER_INTEGRATION = 823; +const EXPECTED_TOOL_COUNT = 10_200; + +// OpenCode's MCP connect timeout (`DEFAULT_TIMEOUT` in its mcp service). The +// Catalog loading has to fit inside it, or the server reads +// "failed" and codemode sees nothing. +const OPENCODE_CONNECT_TIMEOUT_MS = 30_000; + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +/** The one integration that is actually callable: a read and a write against + * the recording upstream. Everything else in the catalog is discovery mass. */ +const notesSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Notes API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/notes": { + get: { + operationId: "listNotes", + summary: "List every note in the notebook", + responses: { "200": { description: "ok" } }, + }, + post: { + operationId: "createNote", + summary: "Create a note in the notebook", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + }, + }, + responses: { "200": { description: "ok" } }, + }, + }, + }, + }); + +interface RecordedRequest { + readonly method: string; + readonly authorization: string | undefined; +} + +const serveRecordingUpstream = Effect.acquireRelease( + Effect.callback<{ + readonly url: string; + readonly requests: RecordedRequest[]; + close: () => void; + }>((resume) => { + const requests: RecordedRequest[] = []; + const server = createServer((request, response) => { + request.on("data", () => undefined); + request.on("end", () => { + requests.push({ + method: request.method ?? "", + authorization: request.headers.authorization, + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ notes: [{ id: "note_0", text: "existing note" }] })); + }); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + requests, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (upstream) => Effect.sync(() => upstream.close()), +); + +scenario( + "Passthrough · the real OpenCode binary runs its own codemode over 10,200 Executor tools", + { timeout: 900_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const opencode = yield* OpenCode; + const runDir = yield* RunDir; + const cli = yield* Cli; + const { client: makeClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const email = identity.credentials?.email ?? identity.label; + const client = yield* makeClient(catalogApi, identity); + const upstream = yield* serveRecordingUpstream; + const notesSlug = unique("notes"); + + // --- Seed: 10,198 discovery-mass tools + 2 callable ones. --- + const seeded = yield* seedLargeCatalog(client, { + syntheticIntegrations: SYNTHETIC_INTEGRATIONS, + opsPerIntegration: OPS_PER_INTEGRATION, + }); + const cleanup = Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(notesSlug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug: notesSlug } }).pipe(Effect.ignore); + yield* seeded.cleanup; + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: notesSpec(upstream.url) }, + slug: notesSlug, + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(notesSlug), + template: AuthTemplateSlug.make("apiKey"), + value: "tok_notes", + }, + }); + const visible = (yield* client.tools.list({ query: {} })).filter( + (tool) => tool.static !== true, + ); + expect(visible.length, "the catalog is exactly the advertised size").toBe( + EXPECTED_TOOL_COUNT, + ); + + // --- The replay brain: two scripted turns of OpenCode codemode. --- + // Turn 0: discover the notes tool through Executor search. + // Turn 1: call the path search returned. Turn 2: summarize, stop. + let discoveredId: string | undefined; + const brain = yield* serveReplayBrain((ctx) => { + // OpenCode also asks the model for a session title, with no tools + // offered. Answer it with text and keep it out of the script. + if (ctx.toolNames.length === 0) return { text: "Notebook" }; + if (ctx.lastRole === "user") { + return { + text: "Searching the connected tools.", + tool: { + name: "execute", + args: { + code: `return await tools.${SERVER_NAME}.search({ query: "${notesSlug} listNotes", limit: 5 });`, + }, + }, + }; + } + if (discoveredId === undefined) { + const result = ctx.lastToolResult ?? ""; + // Search returns an opaque tool ID for the next invoke call. + const match = new RegExp(`"id":\\s*"([^"\\n]*${notesSlug}[^"]*listNotes)"`).exec( + result, + ); + if (!match) { + throw new Error(`search did not surface the notes tool: ${result.slice(0, 600)}`); + } + discoveredId = match[1]!; + return { + text: "Found it. Listing the notes.", + tool: { + name: "execute", + args: { + code: `return await tools.${SERVER_NAME}.invoke({tool: ${JSON.stringify(discoveredId)}, arguments: {}});`, + }, + }, + }; + } + return { text: "The notebook has one existing note." }; + }); + + const passthroughUrl = new URL("/mcp?mode=passthrough", target.baseUrl).toString(); + const home = opencode.makeHome(SERVER_NAME, passthroughUrl, { + chatBrainUrl: brain.baseUrl, + }); + const env = { + ...home.env, + OPENCODE_EXPERIMENTAL_CODE_MODE: "true", + PS1: "$ ", + BASH_SILENCE_DEPRECATION_WARNING: "1", + }; + // First-run database migration happens off camera. + yield* Effect.sync(() => opencode.warmUp(home)); + + let connectMs = -1; + yield* cli.session( + ["bash", "--norc"], + async (term) => { + await term.screen.waitForText("$", { timeoutMs: 10_000 }); + + const outputAfter = (text: string, line: string): string | null => { + const echoed = text.lastIndexOf(line); + if (echoed === -1) return null; + const after = text.slice(echoed + line.length); + return after.trimEnd().endsWith("\n$") ? after : null; + }; + const sh = async (line: string, timeoutMs: number) => { + await term.keyboard.type(line); + await term.keyboard.press("Enter"); + const snapshot = await term.screen.waitUntil( + (current) => outputAfter(current.text, line) !== null, + { timeoutMs }, + ); + return outputAfter(snapshot.text, line) ?? ""; + }; + + // OpenCode's own OAuth against the target: discovery, DCR, PKCE. + const consent = opencode.completeOAuthConsent(home, email, home.openedUrls().length); + const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, 90_000); + await consent; + expect(auth, "opencode mcp auth completes").not.toContain("failed"); + + // The load-bearing connect: catalog loading and two MCP definitions + // inside OpenCode's own 30s connect timeout. + const startedAt = Date.now(); + const listed = await sh("opencode mcp list", 120_000); + connectMs = Date.now() - startedAt; + expect( + listed, + `OpenCode connects to the 10,200-tool passthrough endpoint (took ${connectMs}ms)`, + ).toContain("connected"); + + // A real agent turn: OpenCode's codemode over our catalog. + const ran = await sh(`opencode run "List the notes in my notebook"`, 300_000); + expect(ran, "the run did not error").not.toContain("UnknownError"); + }, + { + cwd: home.projectDir, + env, + record: join(runDir, "terminal.cast"), + viewport: { cols: 100, rows: 40 }, + }, + ); + + // --- What OpenCode showed the model, and what came back. --- + expect(brain.errors(), "the scripted brain hit no surprises").toEqual([]); + // Only the turns that carried tools are the agent loop; the + // title request is OpenCode housekeeping. + const requests = brain.requests().filter((request) => request.toolNames.length > 0); + expect(requests.length, "three model turns: search, call, summary").toBe(3); + + // Codemode: the model sees ONE execute tool, not 10,200 functions. + const offered = requests[0]!.toolNames; + expect(offered, "OpenCode offers its codemode execute tool").toContain("execute"); + expect( + offered.filter((name) => name.startsWith(`${SERVER_NAME}_`)), + "no MCP tool is flattened into the model's tool list", + ).toEqual([]); + expect( + offered.length, + "the model's tool list stays small in front of a 10,200-tool server", + ).toBeLessThan(40); + + // Discovery returned the full underlying tool ID. + expect(discoveredId, "search returned a tool ID").toBeDefined(); + expect(discoveredId, "the ID names the integration tool").toContain(notesSlug); + + // The call went over the wire with the connection's credential and + // the payload came back through OpenCode's interpreter. + const lastToolResult = [...requests[2]!.messages] + .reverse() + .find((message) => message.role === "tool")?.content; + expect(lastToolResult, "the executed program returned the upstream payload").toContain( + "existing note", + ); + const upstreamGet = upstream.requests.find((request) => request.method === "GET"); + expect(upstreamGet, "the GET reached the upstream").toBeDefined(); + expect(upstreamGet?.authorization, "the connection's credential was applied").toBe( + "Bearer tok_notes", + ); + + // Recorded for the run report; the hard bound is OpenCode's own + // connect timeout, which `mcp list` above already proved. + expect(connectMs, "connect stays inside OpenCode's timeout").toBeLessThan( + OPENCODE_CONNECT_TIMEOUT_MS * 4, + ); + }), + cleanup, + ); + }), + ), +); diff --git a/e2e/cloud/passthrough-scale.test.ts b/e2e/cloud/passthrough-scale.test.ts new file mode 100644 index 0000000000..f5513ecf5b --- /dev/null +++ b/e2e/cloud/passthrough-scale.test.ts @@ -0,0 +1,80 @@ +// Large catalogs stay server-side and remain searchable within the connect timeout. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { decodeToolSearch } from "../scenarios/support/search-invoke"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import { catalogApi, seedLargeCatalog } from "../scenarios/support/large-catalog"; + +// Catalog loading and the two-tool handshake must complete within 20 seconds. +const MAX_PASSTHROUGH_CONNECT_MS = 20_000; + +scenario( + "Passthrough · a production-shaped catalog is served completely, in bounded time", + { timeout: 300_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* makeClient(catalogApi, identity); + const seeded = yield* seedLargeCatalog(client); + + yield* Effect.ensuring( + Effect.gen(function* () { + // What the caller can see through the typed API is the ground truth + // search results must cover completely — minus the plugins' + // static configuration tools (`executor.*`, `openapi.addSpec`, …), + // which are codemode affordances and deliberately not served here. + const visible = (yield* client.tools.list({ query: {} })).filter( + (tool) => tool.static !== true, + ); + expect(visible.length, "the seeded catalog is large").toBeGreaterThan(3000); + + const session = mcp.session(identity, { mode: "passthrough" }); + const startedAt = Date.now(); + const served = yield* session.describeTools(); + const elapsedMs = Date.now() - startedAt; + + expect( + elapsedMs, + `a ${visible.length}-tool passthrough connect stays bounded (took ${elapsedMs}ms)`, + ).toBeLessThan(MAX_PASSTHROUGH_CONNECT_MS); + + expect(served.map((tool) => tool.name).sort()).toEqual([ + "integrations", + "invoke", + "search", + "skills", + ]); + const first = decodeToolSearch( + (yield* session.call("search", { query: "org", limit: 20 })).raw, + ).structuredContent; + expect(first.total).toBe(visible.length); + expect(first.items).toHaveLength(20); + expect(first.nextOffset).toBe(20); + const last = decodeToolSearch( + (yield* session.call("search", { query: "org", offset: visible.length - 1 })).raw, + ).structuredContent; + expect(last.items).toHaveLength(1); + expect(last.hasMore).toBe(false); + expect(last.nextOffset).toBeNull(); + for (const slug of seeded.integrationSlugs) { + const found = decodeToolSearch( + (yield* session.call("search", { query: slug })).raw, + ).structuredContent; + expect( + found.items.some((tool) => tool.integration === slug), + `${slug} is discoverable`, + ).toBe(true); + } + }), + seeded.cleanup, + ); + }), + ), +); diff --git a/e2e/desktop-vm/artifact-external-link.test.ts b/e2e/desktop-vm/artifact-external-link.test.ts new file mode 100644 index 0000000000..99b91a5671 --- /dev/null +++ b/e2e/desktop-vm/artifact-external-link.test.ts @@ -0,0 +1,170 @@ +// The packaged desktop app, running in a GUI guest, checking that a link inside +// a generated artifact opens in the user's real browser. +// +// On desktop the console runs artifact UI in a sandbox that can't open popups, +// so a clicked link is handed to the app, which calls window.open. Electron +// then sends plain link clicks out to the system browser instead of opening a +// window inside the app. The cloud e2e covers the click getting that far; this +// covers what happens next, which only a real Electron build can show. +// +// To check it without guessing whether "a browser opened", we point the link at +// a small HTTP server on the host and see who asks for it. If the system +// browser fetches it (its user-agent has no "Electron" in it) and the app +// didn't gain a new window, the link left the app like it should. An in-app +// window would fail on both counts. +import { writeFileSync } from "node:fs"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; + +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { RunDir } from "../src/services"; +import { CdpPage, guestSsh, pageWsUrl, recordGuestScreen, sleep } from "../src/vm/desktop"; + +const NAME = "Desktop (packaged, in a VM) · an artifact link opens in the system browser"; +const cdpPort = process.env.E2E_DESKTOP_CDP_PORT; +const guestIp = process.env.E2E_DESKTOP_VM_IP; +const recSeconds = Number(process.env.E2E_DESKTOP_REC_SECONDS ?? "12"); +const os: "macos" | "linux" | "windows" = + process.env.E2E_TARGET === "desktop-windows" + ? "windows" + : process.env.E2E_TARGET === "desktop-linux" + ? "linux" + : "macos"; + +/** The host's address as seen from the guest. On both tart bridges the guest's + * default gateway is the host, so a link pointed here comes back to us when + * the guest's browser follows it. */ +const hostAddressFromGuest = async (ip: string): Promise => { + const command = + os === "linux" + ? "ip route show default 2>/dev/null | awk '{print $3; exit}'" + : "route -n get default 2>/dev/null | awk '/gateway/{print $2; exit}'"; + const { stdout } = await guestSsh(ip, command); + return stdout.trim(); +}; + +interface OpenProbe { + readonly url: string; + /** The user-agent of whoever fetched the link, or null if nobody did in time. */ + waitForOpen: (timeoutMs: number) => Promise; + close: () => void; +} + +/** A small server the guest's browser hits if the link really left the app. */ +const listenForOpen = async (hostAddress: string): Promise => { + const path = "/opened-from-desktop"; + let seenUserAgent: string | null = null; + let notify: ((ua: string) => void) | null = null; + + const server = http.createServer((req, res) => { + if ((req.url ?? "").startsWith(path)) { + seenUserAgent = String(req.headers["user-agent"] ?? ""); + notify?.(seenUserAgent); + notify = null; + } + res.end("ok"); + }); + await new Promise((resolve) => server.listen(0, "0.0.0.0", () => resolve())); + const { port } = server.address() as AddressInfo; + + return { + url: `http://${hostAddress}:${port}${path}`, + waitForOpen: (timeoutMs: number) => + new Promise((resolve) => { + if (seenUserAgent !== null) return resolve(seenUserAgent); + notify = resolve; + setTimeout(() => { + notify = null; + resolve(seenUserAgent); + }, timeoutMs); + }), + close: () => server.close(), + }; +}; + +/** How many pages the app has open right now — a new in-app window bumps this. */ +const pageTargetCount = async (): Promise => { + const targets = (await fetch(`http://127.0.0.1:${cdpPort}/json/list`) + .then((r) => (r.ok ? r.json() : [])) + .catch(() => [])) as ReadonlyArray<{ type: string }>; + return targets.filter((t) => t.type === "page").length; +}; + +const run = async (runDir: string) => { + const cdp = await CdpPage.connect(await pageWsUrl(Number(cdpPort))); + try { + await cdp.command("Runtime.enable"); + await cdp.command("Page.enable"); + + // Film the guest while we drive it, so the recording shows the browser + // coming to the front when the link opens. + const recording = recordGuestScreen( + guestIp as string, + recSeconds, + join(runDir, "session.mp4"), + os, + ); + + // Wait for the console to load before we do anything with it. + await cdp.waitForText("Integrations", 60_000).catch(() => cdp.waitForText("Settings", 60_000)); + + const hostAddress = await hostAddressFromGuest(guestIp as string); + expect(hostAddress, "the guest reported the host address it routes through").toMatch( + /^\d+\.\d+\.\d+\.\d+$/, + ); + + const probe = await listenForOpen(hostAddress); + try { + const pagesBefore = await pageTargetCount(); + + // The same call the console makes when a `target="_blank"` link is + // clicked (see packages/react/src/api/shell-host.ts). We run it directly + // here — the click-to-open path is already covered by the cloud e2e, and + // what we care about on desktop is what Electron does with this call. + await cdp.command("Runtime.evaluate", { + expression: `window.open(${JSON.stringify(probe.url)}, "_blank", "noopener,noreferrer")`, + }); + + const fetcherUserAgent = await probe.waitForOpen(15_000); + await sleep(1500); + const pagesAfter = await pageTargetCount(); + + writeFileSync(join(runDir, "01-link-opened-externally.png"), await cdp.screenshot()); + + expect(fetcherUserAgent, "the desktop handed the link to the system browser").not.toBeNull(); + expect( + fetcherUserAgent ?? "", + "the OS browser fetched the link, not an in-app Electron window", + ).not.toContain("Electron"); + expect(pagesAfter, "the app opened no in-app browser window for the link").toBe(pagesBefore); + } finally { + probe.close(); + } + + await recording; + } finally { + cdp.close(); + } +}; + +if (!cdpPort || !guestIp || os === "windows") { + const why = + os === "windows" + ? "the host-listener probe needs the tart bridge; the Windows guest attaches over an SSH jump" + : "needs a desktop guest — set E2E_DESKTOP_VM_IP or run the desktop-macos/desktop-linux project"; + it.skip(`${NAME} (${why})`, () => {}); +} else { + // Literal name (not NAME) so the run's test.ts review artifact captures it. + scenario( + "Desktop (packaged, in a VM) · an artifact link opens in the system browser", + { timeout: 180_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => run(runDir)); + }), + ); +} diff --git a/e2e/local/mcp-browser-approve-public-origin.test.ts b/e2e/local/mcp-browser-approve-public-origin.test.ts new file mode 100644 index 0000000000..03d19bb500 --- /dev/null +++ b/e2e/local/mcp-browser-approve-public-origin.test.ts @@ -0,0 +1,234 @@ +// Local-only — the MCP BROWSER-APPROVAL flow, the gap a code review found: +// `resume.$executionId.tsx` POSTed to the bearer-gated `/api/mcp-sessions/*` +// with no Authorization header, so standalone-web approvals 401'd. The existing +// approval scenario (selfhost/mcp-approve.test.ts) approves PROGRAMMATICALLY via +// the MCP `resume` tool (auth on the API path), so it never drives the browser +// page and could not catch this. This drives the real page in a real browser. +// +// Flow: boot `executor web --foreground` → create a require_approval policy on a +// built-in tool → an MCP client (bearer) executes that tool with +// elicitation_mode=browser → the server returns a paused `approvalUrl` → open it +// in the browser (with the `?_token` bootstrap) → click Approve → the MCP +// `resume` call completes. Plus a negative: the approval endpoint 401s without +// the bearer. +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer } from "node:https"; +import { request as httpRequest } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Browser, Cli, RunDir, Target } from "../src/services"; +import { withLocalServer } from "./local-server"; + +const coreApi = composePluginApi([] as const); + +// A built-in, read-only tool to gate (same target the selfhost approval test +// uses) — calling it under a require_approval policy forces the elicitation. +const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; +const EXECUTE_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +const tlsProxy = Effect.acquireRelease( + Effect.promise(async () => { + const dir = mkdtempSync(join(tmpdir(), "executor-tls-proxy-")); + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + join(dir, "key.pem"), + "-out", + join(dir, "cert.pem"), + "-days", + "1", + "-subj", + "/CN=localhost", + ], + { stdio: "ignore" }, + ); + let upstream: string | undefined; + const server = createServer( + { key: readFileSync(join(dir, "key.pem")), cert: readFileSync(join(dir, "cert.pem")) }, + (incoming, outgoing) => { + if (upstream === undefined) { + outgoing.writeHead(503).end(); + return; + } + const request = httpRequest( + new URL(incoming.url ?? "/", upstream), + { + method: incoming.method, + headers: { + ...incoming.headers, + "x-forwarded-proto": "http", + "x-forwarded-host": "untrusted.example", + }, + }, + (response) => { + outgoing.writeHead(response.statusCode ?? 502, response.headers); + response.pipe(outgoing); + }, + ); + request.on("error", () => { + outgoing.writeHead(502).end(); + }); + incoming.pipe(request); + }, + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("TLS proxy did not bind"); + return { + origin: `https://127.0.0.1:${address.port}`, + attach: (origin: string) => { + upstream = origin; + }, + close: () => { + server.closeAllConnections(); + server.close(); + rmSync(dir, { recursive: true, force: true }); + }, + }; + }), + (proxy) => Effect.sync(proxy.close), +); + +scenario( + "Local · TLS proxy approval uses the configured HTTPS origin and resumes in the browser", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const proxy = yield* tlsProxy; + const cli = yield* Cli; + const browser = yield* Browser; + const target = yield* Target; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity(); + + yield* withLocalServer( + cli, + runDir, + (server) => + Effect.gen(function* () { + proxy.attach(server.origin); + // Bearer-authed typed API client (local has no session cookie — the + // credential is the printed token). Used only to plant the policy. + const api = yield* HttpApiClient.make(coreApi, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + yield* api.policies.create({ + payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + const security = await page.context().newCDPSession(page); + await security.send("Security.setIgnoreCertificateErrors", { ignore: true }); + // MCP client over the wire with the bearer (local /mcp is bearer-gated, + // not OAuth — so the raw SDK transport with an Authorization header, + // not mcporter's PKCE flow). elicitation_mode=browser makes the server + // mint an approval URL instead of a model-side pause. + const mcp = new Client( + { name: "e2e-local-approve", version: "1.0.0" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport( + new URL(`${server.origin}/mcp?elicitation_mode=browser`), + { requestInit: { headers: { authorization: `Bearer ${server.token}` } } }, + ); + await mcp.connect(transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the test owns the MCP transport lifecycle + try { + const executed = await mcp.callTool({ + name: "execute", + arguments: { code: EXECUTE_CODE }, + }); + const paused = executed.structuredContent as { + status: string; + executionId: string; + approvalUrl: string; + }; + expect(paused.status, "execute paused for browser approval").toBe( + "user_approval_required", + ); + expect(typeof paused.approvalUrl).toBe("string"); + expect(new URL(paused.approvalUrl).origin).toBe(proxy.origin); + + // The real human flow: approve in the browser FIRST. The page POSTs + // the decision to the bearer-gated /api/mcp-sessions/* endpoint (the + // path bug #2 left unauthenticated) — that just records the decision. + // Calling the MCP `resume` tool first would un-pause the engine and + // the page's getPaused would find nothing, so order matters. + await step("Open the approval URL and approve in the browser", async () => { + const approval = new URL(paused.approvalUrl); + approval.searchParams.set("_token", server.token); // bootstrap the bearer + await page.goto(approval.toString(), { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Approve" }).waitFor({ timeout: 30_000 }); + // The page loaded the paused execution (bearer-authed) — not the + // "unavailable" error branch a getPaused 401/404 would render. + // (Playwright's toBeVisible matcher isn't in vitest's expect.) + expect( + await page.getByText("This paused execution is no longer available").count(), + "approval page loaded the paused execution, not the unavailable branch", + ).toBe(0); + await page.getByRole("button", { name: "Approve" }).click(); + // "Approve sent" only renders if the POST returned 200 — i.e. the + // bearer reached the gated endpoint. Pre-fix it 401'd and stuck. + await page.getByText("Approve sent").waitFor({ timeout: 15_000 }); + }); + + // The agent's `resume` now picks up the recorded approval and the + // engine finishes. + const resumed = await mcp.callTool({ + name: "resume", + arguments: { executionId: paused.executionId }, + }); + const resumedStructured = resumed.structuredContent as { status: string }; + expect( + resumedStructured.status, + "the MCP resume completed once the browser approved (bearer reached the gated endpoint)", + ).toBe("completed"); + + await step("The approval endpoint rejects a request with no bearer", async () => { + const unauthed = await fetch( + `${server.origin}/api/mcp-sessions/${encodeURIComponent( + paused.executionId, + )}/executions/${encodeURIComponent(paused.executionId)}/resume?approval_token=x`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "accept" }), + }, + ); + expect(unauthed.status, "no bearer → 401 at the shell gate").toBe(401); + }); + } finally { + await mcp.close(); + } + }); + }), + { env: { EXECUTOR_WEB_BASE_URL: proxy.origin } }, + ); + }), + ), +); diff --git a/e2e/package.json b/e2e/package.json index 6ddd66d3f3..d605fedf77 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.1", + "@executor-js/emulate": "^0.14.2", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/e2e/scenarios/artifact-hyphenated-path.test.ts b/e2e/scenarios/artifact-hyphenated-path.test.ts new file mode 100644 index 0000000000..d2ec32b5df --- /dev/null +++ b/e2e/scenarios/artifact-hyphenated-path.test.ts @@ -0,0 +1,109 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + ArtifactId, + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, +} from "@executor-js/sdk/shared"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +const decodeCreatedArtifact = Schema.decodeUnknownSync( + Schema.Struct({ structuredContent: Schema.Struct({ artifactId: ArtifactId }) }), +); + +scenario( + "Artifacts · a hyphenated integration renders live tool data through a bracket path", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + const slug = IntegrationSlug.make(`artifact-schema-${randomBytes(4).toString("hex")}`); + const baseUrl = yield* createEmulatorInstance("resend", "artifact-path"); + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + // The emulator's public schema is a real JSON endpoint. Its descriptive + // title gives the rendered query a stable caller-visible result. + yield* client.openapi.addSpec({ + payload: { + slug, + baseUrl, + spec: { + kind: "blob", + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "Service schema", version: "1" }, + servers: [{ url: baseUrl }], + paths: { + "/openapi.json": { + get: { + operationId: "readSchema", + responses: { "200": { description: "Schema" } }, + }, + }, + }, + }), + }, + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("public"), + integration: slug, + template: AuthTemplateSlug.make("none"), + values: {}, + }, + }); + const created = yield* session.call("create-artifact", { + title: `Service schema ${slug}`, + code: `function App() { + const query = useQuery(tools['${slug}'].openapiJson.readSchema.queryOptions({})); + return
{query.isPending ? "Loading schema" : JSON.stringify(query.data ?? query.error)}
; + }`, + }); + expect(created.ok, created.text).toBe(true); + const envelope = decodeCreatedArtifact(created.raw); + artifactId = envelope.structuredContent.artifactId; + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact and read the service schema", async () => { + await visit(page, `/artifacts/${artifactId}`); + const data = page + .frameLocator('[data-testid="artifact-shell-frame"]') + .frameLocator("iframe") + .getByTestId("live-schema"); + await data.waitFor({ timeout: 30_000 }); + await data.filter({ hasText: /Resend/i }).waitFor({ timeout: 30_000 }); + expect(await data.textContent()).toContain("openapi"); + }); + }); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (artifactId !== undefined) + yield* client.artifacts.remove({ params: { artifactId } }).pipe(Effect.ignore); + yield* client.connections + .remove({ + params: { owner: "org", integration: slug, name: ConnectionName.make("public") }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ), + ); + }), +); diff --git a/e2e/scenarios/artifact-source-roundtrip.test.ts b/e2e/scenarios/artifact-source-roundtrip.test.ts new file mode 100644 index 0000000000..e8a056a7a7 --- /dev/null +++ b/e2e/scenarios/artifact-source-roundtrip.test.ts @@ -0,0 +1,77 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([] as const); +const savedArtifact = Schema.Struct({ artifactId: ArtifactId, url: Schema.String }); +const sourceResult = Schema.Struct({ code: Schema.String }); +const structured = Schema.Struct({ structuredContent: Schema.Unknown }); + +scenario( + "Artifacts · text-only clients read current source and edit the same artifact", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + const source = "function App() { return

Original source marker

; }"; + const created = yield* session.call("create-artifact", { + title: "Source round trip", + code: source, + }); + expect(created.ok).toBe(true); + const envelope = yield* Schema.decodeUnknownEffect(structured)(created.raw); + const saved = yield* Schema.decodeUnknownEffect(savedArtifact)(envelope.structuredContent); + yield* Effect.gen(function* () { + const shown = yield* session.call("show-artifact", { id: saved.artifactId }); + expect(shown.ok).toBe(true); + const shownEnvelope = yield* Schema.decodeUnknownEffect(structured)(shown.raw); + const current = yield* Schema.decodeUnknownEffect(sourceResult)( + shownEnvelope.structuredContent, + ); + expect(current.code).toBe(source); + expect(shown.text).toContain(current.code); + const updated = current.code.replace("Original source marker", "Updated source marker"); + const edited = yield* session.call("edit-artifact", { + artifactId: saved.artifactId, + edits: [{ oldText: current.code, newText: updated }], + }); + expect(edited.ok, edited.text).toBe(true); + const afterEdit = yield* session.call("show-artifact", { id: saved.artifactId }); + expect(afterEdit.ok).toBe(true); + const afterEnvelope = yield* Schema.decodeUnknownEffect(structured)(afterEdit.raw); + const afterSource = yield* Schema.decodeUnknownEffect(sourceResult)( + afterEnvelope.structuredContent, + ); + expect(afterSource.code).toBe(updated); + expect(afterEdit.text).toContain(updated); + expect(afterEdit.text).not.toContain("Original source marker"); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact edited from its returned source", async () => { + await visit(page, saved.url); + await page + .frameLocator('[data-testid="artifact-shell-frame"]') + .frameLocator("iframe") + .getByText("Updated source marker", { exact: true }) + .waitFor({ timeout: 30_000 }); + }); + }); + }).pipe( + Effect.ensuring( + client.artifacts.remove({ params: { artifactId: saved.artifactId } }).pipe( + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: cleanup must fail the scenario if the API cannot remove the fixture + Effect.orDie, + ), + ), + ); + }), +); diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts index fa9a82d03b..2b8f700dba 100644 --- a/e2e/scenarios/artifacts.test.ts +++ b/e2e/scenarios/artifacts.test.ts @@ -51,13 +51,18 @@ const api = composePluginApi([] as const); */ const ARTIFACT_ROW_COUNT = 40; -const artifactSource = (marker: string) => ` +const artifactSource = (marker: string, linkUrl?: string) => ` function App() { return (

Release Readiness

${marker}

+ ${ + linkUrl === undefined + ? "" + : `
Open pull request` + }
{Array.from({ length: ${ARTIFACT_ROW_COUNT} }, (_, i) => ( @@ -144,14 +149,14 @@ const recordHandshakeOrdering = async (page: Page): Promise => { const readHandshakeOrdering = (page: Page): Promise> => page.evaluate(() => globalThis.__handshakeOrder ?? []); -const readConsoleStyle = (page: Page): Promise<{ primary: string; buttonBg: string }> => - page.evaluate(() => { - const button = document.querySelector("button"); - return { - primary: getComputedStyle(document.documentElement).getPropertyValue("--primary").trim(), - buttonBg: button ? getComputedStyle(button).backgroundColor : "", - }; - }); +const readConsoleStyle = async (page: Page): Promise<{ primary: string; buttonBg: string }> => { + const button = page.getByRole("button", { name: "Rename", exact: true }); + await button.waitFor(); + return button.evaluate((element) => ({ + primary: getComputedStyle(document.documentElement).getPropertyValue("--primary").trim(), + buttonBg: getComputedStyle(element).backgroundColor, + })); +}; // The shell's compiled stylesheet declares `--mcp-apps-shell-stylesheet: 1` // on `:root` as a provenance marker (see the shell's globals.css): the shell's @@ -187,6 +192,8 @@ scenario( const suffix = uniqueSuffix(); const title = `Release Readiness ${suffix}`; const marker = `artifact-ok-${suffix}`; + const pullRequestUrl = new URL("/policies?from=artifact-link", target.baseUrl).toString(); + const source = artifactSource(marker, pullRequestUrl).trim(); // Tracked so cleanup runs even when an assertion below fails. let artifactId: ArtifactId | undefined; @@ -215,7 +222,7 @@ scenario( ); const rendered = yield* session.call("create-artifact", { - code: artifactSource(marker), + code: source, title, description: "Whether the current release is ready to ship", }); @@ -308,6 +315,19 @@ scenario( .not.toContain("Connecting"); }); + await step("A pull request link opens with a normal left-click", async () => { + const openedPage = page.context().waitForEvent("page"); + await artifactContent(page).getByTestId("artifact-pr-link").click(); + const popup = await openedPage; + await popup.waitForURL(pullRequestUrl, { timeout: 20_000 }); + + expect( + popup.url(), + "the sandbox handed the link to the host, which opened a new tab", + ).toBe(pullRequestUrl); + await popup.close(); + }); + await step("The host was listening before the shell could speak", async () => { // The regression guard for the handshake race. Asserting only that the // artifact rendered is not enough: the previous implementation @@ -552,6 +572,10 @@ scenario( String(structuredOf(shown).url ?? shown.text), "show-artifact delivers the same deep link for a non-Apps client", ).toContain(String(artifactId)); + expect( + shown.text, + "show-artifact includes the current source in its text result for a non-Apps client", + ).toContain(`Source:\n\`\`\`tsx\n${source}\n\`\`\``); }).pipe( Effect.ensuring( Effect.suspend(() => @@ -582,8 +606,10 @@ scenario( const suffix = uniqueSuffix(); const originalTitle = `Draft Dashboard ${suffix}`; const renamedTitle = `Quarterly Dashboard ${suffix}`; + const listDeleteTitle = `List Card ${suffix}`; let artifactId: ArtifactId | undefined; + let listArtifactId: ArtifactId | undefined; yield* Effect.gen(function* () { const rendered = yield* session.call("create-artifact", { @@ -633,26 +659,88 @@ scenario( originalTitle, ); + // A second artifact for the gallery card's own delete path: the detail + // page steps below consume the renamed one, and the card's hover → + // Delete affordance is a separate surface the console must keep working. + const listed = yield* session.call("create-artifact", { + code: artifactSource(`list-delete-${suffix}`), + title: listDeleteTitle, + description: "A dashboard the user will delete from the gallery", + }); + expect(listed.ok, `create-artifact succeeded: ${listed.text}`).toBe(true); + listArtifactId = structuredOf(listed).artifactId as ArtifactId; + expect(listArtifactId, "the second artifact was persisted").toBeTruthy(); + yield* browser.session(identity, async ({ page, step }) => { - await step("Delete the artifact from the list", async () => { + await step("Delete the artifact from its gallery card", async () => { await visit(page, "/artifacts"); + // Card actions reveal on hover; the card is the link's enclosing tile. const card = page.locator('[data-slot="artifact-card"]').filter({ - hasText: renamedTitle, + hasText: listDeleteTitle, }); await card.waitFor({ timeout: 20_000 }); await card.hover(); await card.getByRole("button", { name: "Delete" }).click(); const confirm = page.getByRole("alertdialog"); - await confirm.getByRole("heading", { name: `Delete ${renamedTitle}?` }).waitFor(); + await confirm.getByRole("heading", { name: `Delete ${listDeleteTitle}?` }).waitFor(); await confirm.getByRole("button", { name: "Delete Artifact" }).click(); await confirm.waitFor({ state: "hidden", timeout: 20_000 }); + await page + .getByRole("link", { name: `Open artifact ${listDeleteTitle}` }) + .waitFor({ state: "detached", timeout: 20_000 }); }); - await step("The artifact is gone from the list", async () => { - await page + let releaseListRefresh = () => {}; + let markListRefreshStarted = () => {}; + const listRefreshGate = new Promise((resolve) => { + releaseListRefresh = resolve; + }); + const listRefreshStarted = new Promise((resolve) => { + markListRefreshStarted = resolve; + }); + + await step("Open the artifact and delete it from its detail page", async () => { + await visit(page, "/artifacts"); + await page.getByRole("link", { name: `Open artifact ${renamedTitle}` }).click(); + await page.getByRole("heading", { name: renamedTitle }).waitFor({ timeout: 20_000 }); + + // Hold the post-delete list refresh open. The redirected gallery must + // carry the optimistic removal across the route handoff rather than + // relying on a fast canonical response to hide a stale-cache flash. + await page.route("**/artifacts", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + markListRefreshStarted(); + await listRefreshGate; + await route.continue(); + }); + + await page.getByRole("button", { name: "Delete" }).click(); + const confirm = page.getByRole("alertdialog"); + await confirm.getByRole("heading", { name: `Delete ${renamedTitle}?` }).waitFor(); + await confirm.getByRole("button", { name: "Delete Artifact" }).click(); + }); + + await step("The redirected gallery already omits the deleted artifact", async () => { + await page.waitForURL((url) => /\/artifacts\/?$/.test(url.pathname), { + timeout: 20_000, + }); + await page.getByRole("heading", { name: "Saved artifacts" }).waitFor({ timeout: 20_000 }); + await listRefreshStarted; + + const deletedCardCount = await page .getByRole("link", { name: `Open artifact ${renamedTitle}` }) - .waitFor({ state: "detached", timeout: 20_000 }); + .count(); + releaseListRefresh(); + await page.unrouteAll({ behavior: "wait" }); + + expect( + deletedCardCount, + "the optimistic delete survives navigation while the list refresh is pending", + ).toBe(0); }); }); @@ -660,12 +748,22 @@ scenario( expect(afterDelete.text, "the agent no longer offers the deleted artifact").not.toContain( renamedTitle, ); + expect(afterDelete.text, "nor the artifact deleted from its gallery card").not.toContain( + listDeleteTitle, + ); const missing = yield* session.call("show-artifact", { id: artifactId }); expect(missing.ok, "fetching a deleted artifact is an error, not an empty render").toBe( false, ); }).pipe( + Effect.ensuring( + Effect.suspend(() => + listArtifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId: listArtifactId } }), + ).pipe(Effect.ignore), + ), Effect.ensuring( Effect.suspend(() => artifactId === undefined diff --git a/e2e/scenarios/google-analyticsdata-discovery.test.ts b/e2e/scenarios/google-analyticsdata-discovery.test.ts new file mode 100644 index 0000000000..5a18a94822 --- /dev/null +++ b/e2e/scenarios/google-analyticsdata-discovery.test.ts @@ -0,0 +1,57 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +scenario( + "Google Analytics Data · public Discovery import exposes report tools", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + const identity = yield* target.newIdentity(); + const apiClient = yield* client(api, identity); + const slug = IntegrationSlug.make(`analytics_${randomBytes(4).toString("hex")}`); + // Fetch Google's public document through the real server import path. + // No Analytics account or authenticated report request is needed. + const added = yield* apiClient.openapi.addSpec({ + payload: { + slug, + spec: { + kind: "url", + url: "https://www.googleapis.com/discovery/v1/apis/analyticsdata/v1beta/rest", + }, + }, + }); + yield* Effect.gen(function* () { + expect(added.toolCount).toBeGreaterThan(0); + const providers = yield* apiClient.providers.list(); + const provider = providers[0]; + if (provider === undefined) return yield* Effect.die("No credential provider available"); + yield* apiClient.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("googleOAuth2"), + from: { provider, id: ProviderItemId.make(randomUUID()) }, + }, + }); + const tools = yield* apiClient.tools.list({ query: { integration: slug } }); + expect(tools.some((tool) => tool.name.endsWith("runReport"))).toBe(true); + expect(tools.some((tool) => tool.name.endsWith("runRealtimeReport"))).toBe(true); + }).pipe(Effect.ensuring(apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.orDie))); + }), +); diff --git a/e2e/scenarios/health-check-rpc-ui.test.ts b/e2e/scenarios/health-check-rpc-ui.test.ts new file mode 100644 index 0000000000..b6bf13df25 --- /dev/null +++ b/e2e/scenarios/health-check-rpc-ui.test.ts @@ -0,0 +1,238 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; +import { variable } from "@executor-js/sdk/http-auth"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +const template = AuthTemplateSlug.make("apiKey"); +const name = ConnectionName.make("test"); +const authenticationTemplate = [ + { + slug: template, + type: "apiKey" as const, + headers: { authorization: ["Bearer ", variable("token")] }, + }, +]; +const spec = (baseUrl: string) => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "RPC account health", version: "1" }, + servers: [{ url: baseUrl }], + paths: { + "/api/auth.test": { + post: { + operationId: "getAccount", + requestBody: { + required: true, + content: { "application/json": { schema: { type: "object" } } }, + }, + responses: { "200": { description: "OK" } }, + }, + }, + "/account": { + delete: { operationId: "deleteAccount", responses: { "204": { description: "Deleted" } } }, + }, + }, + }); + +scenario( + "Health checks (UI) · configure and run a POST probe with a warning", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`hc-rpc-${randomBytes(4).toString("hex")}`); + const baseUrl = yield* createEmulatorInstance("slack", "rpc-health"); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + const credential = yield* Effect.promise(() => + emulator.credentials.mint({ type: "bearer-token" }), + ); + const token = credential.token; + if (!token) return yield* Effect.die("Emulator did not mint a bearer token"); + const body = { include: { identity: true }, fields: ["user", "team"] }; + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + slug, + baseUrl, + spec: { kind: "blob", value: spec(baseUrl) }, + authenticationTemplate, + }, + }); + yield* browser.session(identity, async ({ page, step }) => { + await step("Choose a POST health check and read its warning", async () => { + await visit(page, `/integrations/${slug}`); + await page.getByRole("button", { name: "Set up", exact: true }).click(); + await page + .getByRole("combobox", { name: "Operation", exact: true }) + .fill("getAccount"); + await page.getByRole("option", { name: /POST.*getAccount/ }).click(); + await page + .getByRole("alert") + .filter({ hasText: "POST requests can change data." }) + .waitFor(); + }); + await step("Reject malformed JSON before running or saving", async () => { + await page.getByRole("textbox", { name: "Request body (JSON)" }).fill('{"include":'); + expect( + await page.getByRole("button", { name: "Save", exact: true }).isEnabled(), + ).toBe(false); + await page.getByText("Enter a valid JSON request body.").waitFor(); + }); + await step("Preview the POST read with a JSON request body", async () => { + await page + .getByRole("textbox", { name: "Request body (JSON)" }) + .fill(JSON.stringify(body)); + await page.getByLabel("Test credential", { exact: true }).fill(token); + await page.getByRole("button", { name: "Preview", exact: true }).click(); + await page.getByText("Healthy", { exact: true }).waitFor(); + await page.getByText("Healthy", { exact: true }).scrollIntoViewIfNeeded(); + }); + await step("Save the health check and reopen its JSON body", async () => { + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.locator("#health-check-operation").waitFor({ state: "hidden" }); + await page.reload(); + const section = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Health check", exact: true }), + }); + await section.getByRole("button", { name: "Edit", exact: true }).click(); + expect( + JSON.parse( + await page.getByRole("textbox", { name: "Request body (JSON)" }).inputValue(), + ), + ).toEqual(body); + await page + .getByRole("alert") + .filter({ hasText: "POST requests can change data." }) + .waitFor(); + }); + }); + const saved = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(saved?.args).toEqual({ body }); + yield* client.connections.create({ + payload: { owner: "org", integration: slug, name, template, value: token }, + }); + const result = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + query: {}, + }); + expect(result.status).toBe("healthy"); + expect(result.httpStatus).toBe(200); + const requests = yield* Effect.promise(() => emulator.ledger.list()); + const probes = requests.filter((request) => request.path === "/api/auth.test"); + expect( + probes.length, + "preview and saved-connection check both reach the upstream", + ).toBeGreaterThanOrEqual(2); + for (const probe of probes) { + expect(probe.method).toBe("POST"); + expect(probe.request.body).toEqual(body); + expect(probe.response.status).toBe(200); + expect(probe.response.body).toMatchObject({ ok: true }); + expect(probe.sideEffects).toEqual([]); + } + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + yield* Effect.promise(() => emulator.reset()); + }), + ); + }), + ), +); + +scenario( + "Health checks (UI) · explain why an unsupported method cannot run", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`hc-refused-${randomBytes(4).toString("hex")}`); + const baseUrl = "https://example.invalid"; + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + slug, + baseUrl, + spec: { kind: "blob", value: spec(baseUrl) }, + authenticationTemplate, + }, + }); + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const mutation = candidates.find((candidate) => candidate.method === "delete"); + if (!mutation) return yield* Effect.die("Expected an unsupported DELETE operation"); + yield* client.integrations.healthCheckSet({ + params: { slug }, + payload: { spec: { operation: mutation.operation } }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + integration: slug, + name, + template, + value: "test-only-credential", + }, + }); + const result = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + query: {}, + }); + expect(result.status).toBe("unknown"); + expect(result.httpStatus).toBeUndefined(); + expect(result.detail).toContain("not supported for health checks"); + yield* browser.session(identity, async ({ page, step }) => { + await step("See why the configured health check could not run", async () => { + await visit(page, `/integrations/${slug}`); + await page.getByText(result.detail!, { exact: true }).waitFor(); + expect( + await page.getByText("No health check configured.", { exact: true }).count(), + ).toBe(0); + }); + await step("Edit the unsupported operation to see how to fix it", async () => { + const section = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Health check", exact: true }), + }); + await section.getByRole("button", { name: "Edit", exact: true }).click(); + await page + .getByText("This method is not supported for health checks.", { exact: false }) + .waitFor(); + expect( + await page.getByRole("button", { name: "Save", exact: true }).isEnabled(), + ).toBe(false); + }); + }); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/e2e/scenarios/health-checks.test.ts b/e2e/scenarios/health-checks.test.ts index cb8adf53c0..c24a763ad2 100644 --- a/e2e/scenarios/health-checks.test.ts +++ b/e2e/scenarios/health-checks.test.ts @@ -70,6 +70,11 @@ const identitySpec = (baseUrl: string): string => summary: "Send a message", responses: { "201": { description: "created" } }, }, + delete: { + operationId: "deleteMessages", + summary: "Delete messages", + responses: { "204": { description: "deleted" } }, + }, }, }, }); @@ -486,14 +491,13 @@ scenario( Effect.gen(function* () { yield* registerIdentityIntegration(client, slug, server.url); - // Deliberately declare the DESTRUCTIVE POST as the health check (the - // editor warns but allows saving; the runtime is the enforcement). + // API configuration must not bypass the runtime's method restriction. const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); - const post = candidates.find((candidate) => candidate.method === "post"); - if (!post) return yield* Effect.die("identity spec exposed no POST candidate"); + const mutation = candidates.find((candidate) => candidate.method === "delete"); + if (!mutation) return yield* Effect.die("identity spec exposed no DELETE candidate"); yield* client.integrations.healthCheckSet({ params: { slug }, - payload: { spec: { operation: post.operation } }, + payload: { spec: { operation: mutation.operation } }, }); yield* client.connections.create({ @@ -508,13 +512,15 @@ scenario( // A health check runs unattended and repeatedly with no approval // gate, so the probe REFUSES to execute a mutating operation: the - // result is unknown-with-reason and the upstream never sees a POST. + // result is unknown-with-reason and the upstream never sees a DELETE. const result = yield* client.connections.checkHealth({ params: { owner: "org", integration: slug, name }, query: {}, }); expect(result.status, "a mutating probe refuses to run").toBe("unknown"); - expect(result.detail ?? "", "the refusal names the problem").toContain("mutating"); + expect(result.detail ?? "", "the refusal names the problem").toContain( + "not supported for health checks", + ); expect(result.httpStatus, "no request reached the upstream").toBeUndefined(); }), Effect.gen(function* () { diff --git a/e2e/scenarios/mcp-approval-persistence.test.ts b/e2e/scenarios/mcp-approval-persistence.test.ts new file mode 100644 index 0000000000..0a93956845 --- /dev/null +++ b/e2e/scenarios/mcp-approval-persistence.test.ts @@ -0,0 +1,80 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { makeElicitationMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { parseBrowserApproval } from "../src/surfaces/mcp"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "MCP · browser approval preserves the chosen lifetime and defaults to once", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`approval_terms_${randomBytes(4).toString("hex")}`); + const server = yield* serveMcpServer(makeElicitationMcpServer); + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Approval terms", + endpoint: server.url, + slug, + remoteTransport: "streamable-http", + }, + }); + yield* Effect.gen(function* () { + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("none"), + value: "", + }, + }); + const session = mcp.session(identity, { elicitationMode: "browser" }); + yield* session.listTools(); + for (const scope of ["session", "always", ""] as const) { + const paused = yield* session.call("execute", { + code: `return await tools.${slug}.org.main.remembered_echo({value:"browser"});`, + }); + const approval = parseBrowserApproval(paused); + const [resumed] = yield* Effect.all( + [ + session.awaitResume(approval.executionId), + browser.session(identity, async ({ page, step }) => { + await step(`Approve ${scope || "once"} through the console`, async () => { + await visit(page, approval.approvalUrl); + const choice = page.getByLabel("Remember this approval"); + await choice.waitFor(); + expect(await choice.inputValue(), "every approval starts as one-time").toBe(""); + if (scope !== "") await choice.selectOption(scope); + await page.getByRole("button", { name: "Approve", exact: true }).click(); + await page.getByText("Approve sent").waitFor(); + }); + }), + ], + { concurrency: "unbounded" }, + ); + expect(resumed.ok).toBe(true); + expect(resumed.text, "the chosen lifetime reaches the upstream MCP server").toContain( + `approved:browser:${scope || "once"}`, + ); + } + }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie))); + }), + ), +); diff --git a/e2e/scenarios/mcp-passthrough.test.ts b/e2e/scenarios/mcp-passthrough.test.ts new file mode 100644 index 0000000000..827982f316 --- /dev/null +++ b/e2e/scenarios/mcp-passthrough.test.ts @@ -0,0 +1,373 @@ +// Search discovers schemas; invoke reaches the upstream and enforces workspace blocks. +import { randomBytes } from "node:crypto"; +import { createServer, type IncomingMessage } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ToolAddress, +} from "@executor-js/sdk/shared"; + +import { decodeToolSearch } from "./support/search-invoke"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit, settle } from "../src/surfaces/browser"; + +const decodeInventory = Schema.decodeUnknownSync( + Schema.Struct({ + structuredContent: Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + integration: Schema.String, + owner: Schema.String, + connection: Schema.String, + }), + ), + total: Schema.Number, + }), + }), +); + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +/** A two-operation API: a read and a write, so the surface carries one tool + * per policy outcome. The write takes a JSON body with a shared `$ref`, so + * the advertised schema must be self-contained to be usable. */ +const spec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Passthrough API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + components: { + schemas: { + NewNote: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + }, + paths: { + "/notes": { + get: { + operationId: "listNotes", + summary: "List notes", + responses: { "200": { description: "ok" } }, + }, + post: { + operationId: "createNote", + summary: "Create a note", + requestBody: { + required: true, + content: { + "application/json": { schema: { $ref: "#/components/schemas/NewNote" } }, + }, + }, + responses: { "200": { description: "ok" } }, + }, + }, + }, + }); + +interface RecordedRequest { + readonly method: string; + readonly path: string; + readonly authorization: string | undefined; + readonly body: string; +} + +/** A real upstream that records what reached it, so a passthrough call can be + * proven to have gone over the wire with the connection's credential. */ +const serveRecordingUpstream = Effect.acquireRelease( + Effect.callback<{ + readonly url: string; + readonly requests: RecordedRequest[]; + close: () => void; + }>((resume) => { + const requests: RecordedRequest[] = []; + const readBody = (request: IncomingMessage) => + new Promise((resolve) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + const server = createServer((request, response) => { + void readBody(request).then((body) => { + requests.push({ + method: request.method ?? "", + path: request.url ?? "", + authorization: request.headers.authorization, + body, + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify( + request.method === "POST" + ? { id: "note_1", ...(body ? (JSON.parse(body) as object) : {}) } + : { notes: [{ id: "note_0", text: "existing" }] }, + ), + ); + }); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + requests, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (upstream) => Effect.sync(() => upstream.close()), +); + +const rawResultOf = (result: { readonly raw: unknown }) => + result.raw as { + content?: ReadonlyArray<{ type: string; text?: string }>; + structuredContent?: Record; + isError?: boolean; + }; + +scenario( + "Passthrough · a session connected with mode=passthrough serves search and invoke with schemas and enforced blocks", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const upstream = yield* serveRecordingUpstream; + const slug = unique("ptapi"); + const otherSlug = unique("ptother"); + + yield* Effect.ensuring( + Effect.gen(function* () { + // Two integrations, one connection each: both must appear on the surface. + for (const s of [slug, otherSlug]) { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: spec(upstream.url) }, + slug: s, + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(s), + template: AuthTemplateSlug.make("apiKey"), + value: `tok_${s}`, + }, + }); + } + + yield* browser.session(identity, async ({ page, step }) => { + await step("Choose Search and invoke in the Connect card", async () => { + await visit(page, "/"); + await page.getByRole("button", { name: "Advanced" }).click(); + await page.getByRole("switch", { name: "Search and invoke" }).check(); + await settle(page); + expect(await page.locator("code").first().innerText()).toContain("mode=passthrough"); + expect( + await page + .getByText( + "Discover connected accounts with integrations and read the guide with skills.", + { exact: false }, + ) + .isVisible(), + ).toBe(true); + }); + }); + + const codemode = mcp.session(identity); + expect(yield* codemode.listTools()).toContain("execute"); + + const passthrough = mcp.session(identity, { mode: "passthrough" }); + const described = yield* passthrough.describeTools(); + expect(described.map((tool) => tool.name).sort()).toEqual([ + "integrations", + "invoke", + "search", + "skills", + ]); + expect(described.find((tool) => tool.name === "search")?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(described.find((tool) => tool.name === "invoke")?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + }); + const inventory = yield* passthrough.call("integrations", { + integration: slug, + owner: "org", + }); + expect(inventory.ok).toBe(true); + const decodedInventory = decodeInventory(inventory.raw).structuredContent; + expect(decodedInventory).toEqual({ + items: [{ integration: slug, owner: "org", connection: "main" }], + total: 1, + }); + expect(inventory.text).not.toContain(`tok_${slug}`); + const skills = yield* passthrough.call("skills", {}); + expect(skills.ok).toBe(true); + expect(skills.text).toContain("search-invoke"); + const guide = yield* passthrough.call("skills", { name: "search-invoke" }); + expect(guide.ok).toBe(true); + expect(guide.text).toContain("integrations({})"); + expect((yield* passthrough.call("skills", { name: "execute" })).ok).toBe(false); + const found = decodeToolSearch( + (yield* passthrough.call("search", { + query: "notes", + integration: slug, + owner: "org", + connection: "main", + })).raw, + ).structuredContent; + expect(found.items.every((tool) => tool.integration === slug)).toBe(true); + const missingAccount = decodeToolSearch( + (yield* passthrough.call("search", { + query: "notes", + integration: slug, + owner: "user", + connection: "main", + })).raw, + ).structuredContent; + expect(missingAccount.items).toEqual([]); + const listDef = found.items.find((tool) => tool.id.endsWith(".listNotes")); + const createDef = found.items.find((tool) => tool.id.endsWith(".createNote")); + if (!listDef || !createDef) return yield* Effect.die("Search omitted notes operations"); + const listTool = listDef.id; + const createTool = createDef.id; + expect(listDef).toMatchObject({ + integration: slug, + owner: "org", + connection: "main", + }); + const schemaView = yield* client.tools.schema({ + query: { address: ToolAddress.make(createTool) }, + }); + expect(createDef.annotations).toEqual(schemaView.annotations); + expect(JSON.stringify(createDef.inputSchema)).toContain("text"); + const other = decodeToolSearch( + (yield* passthrough.call("search", { query: otherSlug })).raw, + ).structuredContent; + expect(other.items.some((tool) => tool.integration === otherSlug)).toBe(true); + + // --- A read call reaches the upstream with the connection's credential. --- + const listed = yield* passthrough.call("invoke", { tool: listTool, arguments: {} }); + expect(listed.ok, `the read call completes: ${listed.text}`).toBe(true); + expect(listed.text, "the upstream payload comes back").toContain("existing"); + const listReq = upstream.requests.find((r) => r.method === "GET"); + expect(listReq, "the GET reached the upstream").toBeDefined(); + expect(listReq?.authorization, "the connection's credential was applied").toBe( + `Bearer tok_${slug}`, + ); + + // --- An approval-gated call runs to completion: no pause, no resume. --- + const created = yield* passthrough.call("invoke", { + tool: createTool, + arguments: { body: { text: "hello" } }, + }); + expect(created.ok, `the gated call completes without a pause: ${created.text}`).toBe( + true, + ); + expect(created.text, "the call did not pause").not.toContain("Execution paused"); + expect(created.text, "the call did not ask for a resume").not.toContain("executionId"); + const createReq = upstream.requests.find((r) => r.method === "POST"); + expect(createReq, "the POST reached the upstream").toBeDefined(); + expect(createReq?.body, "the JSON body went over the wire").toContain('"text":"hello"'); + expect( + rawResultOf(created).structuredContent?.status, + "the result is a completed execution", + ).toBe("completed"); + + // --- Arguments are validated against the advertised schema. --- + // An MCP error result must not reach the upstream. + const invalid = yield* passthrough + .call("invoke", { tool: createTool, arguments: { body: { wrong: 1 } } }) + .pipe( + Effect.map((r) => r.ok), + Effect.catchCause(() => Effect.succeed(false)), + ); + expect(invalid, "a body missing its required field is refused").toBe(false); + expect( + upstream.requests.filter((r) => r.method === "POST").length, + "the invalid call never reached the upstream", + ).toBe(1); + + // --- `block` is enforced on the list AND the call. --- + const blockRule = yield* client.policies.create({ + payload: { owner: "org", pattern: `${slug}.*.*.*.createNote`, action: "block" }, + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const afterBlock = mcp.session(identity, { mode: "passthrough" }); + const afterNames = decodeToolSearch( + (yield* afterBlock.call("search", { query: slug })).raw, + ).structuredContent.items.map((tool) => tool.id); + expect(afterNames, "a blocked tool is not listed").not.toContain(createTool); + expect(afterNames, "the unblocked sibling still is").toContain(listTool); + // A client that cached the old name cannot call it either: the + // executor refuses the call at invoke time, which passthrough + // renders as an MCP error result. + const stale = yield* passthrough.call("invoke", { + tool: createTool, + arguments: { body: { text: "again" } }, + }); + expect(stale.ok, "a blocked tool fails when called").toBe(false); + expect(stale.text, "the failure names the policy").toContain("blocked"); + expect( + upstream.requests.filter((r) => r.method === "POST").length, + "the blocked call never reached the upstream", + ).toBe(1); + }), + client.policies + .remove({ params: { policyId: blockRule.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ); + }), + Effect.gen(function* () { + for (const s of [slug, otherSlug]) { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(s), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug: s } }).pipe(Effect.ignore); + } + }), + ); + }), + ), +); diff --git a/e2e/scenarios/openapi-unreachable-artifact.test.ts b/e2e/scenarios/openapi-unreachable-artifact.test.ts new file mode 100644 index 0000000000..1b5adae46e --- /dev/null +++ b/e2e/scenarios/openapi-unreachable-artifact.test.ts @@ -0,0 +1,226 @@ +// Cross-target: an artifact whose OpenAPI query cannot reach its upstream gets +// an actionable network error, not the opaque defect mask. This walks the real +// path from a saved artifact through the nested shell, execute-action, sandbox, +// OpenAPI transport, and back into ArtifactError. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { ConnectionName, IntegrationSlug, type ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import type { McpSession } from "../src/surfaces/mcp"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +type DroppingUpstream = { + readonly url: string; + readonly requests: () => number; + readonly close: () => void; +}; + +// Accept the request, then drop the socket before sending response headers. +// This produces a real transport failure without relying on a hardcoded or +// temporarily-unused port. +const serveDroppingUpstream = () => + Effect.acquireRelease( + Effect.callback((resume) => { + let hits = 0; + const server = createServer((_request, response) => { + hits += 1; + response.destroy(); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + requests: () => hits, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const unreachableSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Unreachable API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/things": { + get: { + tags: ["things"], + operationId: "listThings", + summary: "List things", + responses: { + "200": { + description: "Things", + content: { + "application/json": { + schema: { type: "array", items: { type: "object" } }, + }, + }, + }, + }, + }, + }, + }, + }); + +const createConnectionCode = (slug: string) => ` +const created = await tools.executor.coreTools.connections.create({ + owner: "org", + name: "public", + integration: ${JSON.stringify(slug)}, + template: "none", +}); +return JSON.stringify(created.ok ? { ok: true } : { ok: false, error: created.error }); +`; + +const executeApproved = (session: McpSession, code: string) => + Effect.gen(function* () { + let result = yield* session.call("execute", { code }); + let guard = 0; + while (result.text.includes("executionId:") && guard < 10) { + result = yield* session.approvePaused(result.text); + guard += 1; + } + expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); + return result.text; + }); + +const artifactSource = (slug: string) => ` +function App() { + const query = useQuery(tools.${slug}.things.listThings.queryOptions({})); + const result = query.data; + return ( +
+

Upstream status

+
+ {query.isLoading ? ( + + ) : query.error ? ( + + ) : result?.ok === false ? ( + + ) : ( +

Unexpected upstream success

+ )} +
+
+ ); +} +`; + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +scenario( + "Artifacts · an unreachable OpenAPI host shows actionable retry guidance instead of an internal error", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + const upstream = yield* serveDroppingUpstream(); + const slug = unique("unreachable"); + const title = `Unreachable upstream ${randomBytes(4).toString("hex")}`; + let artifactId: ArtifactId | undefined; + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: unreachableSpec(upstream.url) }, + slug, + baseUrl: upstream.url, + }, + }); + + const created = yield* executeApproved(session, createConnectionCode(slug)); + expect(created, `the no-auth connection was created: ${created}`).toContain('"ok":true'); + + const rendered = yield* session.call("create-artifact", { + code: artifactSource(slug), + title, + description: "Shows whether the upstream API is reachable", + connections: { [slug]: `${slug}.org.public` }, + }); + expect(rendered.ok, `create-artifact succeeded: ${rendered.text}`).toBe(true); + + const structured = structuredOf(rendered); + artifactId = structured.artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted").toBeTruthy(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact that reads from the unreachable API", async () => { + await visit(page, String(structured.url)); + await page.getByRole("heading", { name: title }).waitFor({ timeout: 20_000 }); + }); + + await step( + "The artifact explains that the upstream host could not be reached", + async () => { + const state = artifactContent(page).getByTestId("upstream-state"); + await state.locator('[data-slot="artifact-error"]').waitFor({ timeout: 30_000 }); + const message = await state.innerText(); + + expect(message, "the user gets actionable network guidance").toContain( + `Could not reach the upstream server for "${slug}"`, + ); + expect(message, "the opaque defect mask never reaches the artifact").not.toContain( + "Internal tool error", + ); + expect(message, "the request path is not leaked").not.toContain("/things"); + }, + ); + }); + + expect(upstream.requests(), "the artifact made a real upstream request").toBeGreaterThan( + 0, + ); + }), + Effect.gen(function* () { + if (artifactId !== undefined) { + yield* client.artifacts.remove({ params: { artifactId } }).pipe(Effect.ignore); + } + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("public"), + }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/e2e/scenarios/reduced-motion.test.ts b/e2e/scenarios/reduced-motion.test.ts new file mode 100644 index 0000000000..f2e751421e --- /dev/null +++ b/e2e/scenarios/reduced-motion.test.ts @@ -0,0 +1,59 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +scenario( + "Accessibility · reduced motion removes dialog animation and control transitions", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the API key dialog with normal motion", async () => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await visit(page, "/api-keys"); + await page.getByRole("button", { name: "New key" }).click(); + await page.getByRole("dialog").waitFor(); + const duration = await page + .getByRole("dialog") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)); + expect(duration, "normal motion retains the dialog transition").toBeGreaterThan(0.00001); + }); + + await step("Enable reduced motion while the dialog is open", async () => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const styles = await page.getByRole("dialog").evaluate((element) => { + const style = getComputedStyle(element); + return { + animation: Number.parseFloat(style.animationDuration), + iterations: style.animationIterationCount, + transition: Number.parseFloat(style.transitionDuration), + scroll: style.scrollBehavior, + }; + }); + expect(styles).toEqual({ + animation: 0.00001, + iterations: "1", + transition: 0.00001, + scroll: "auto", + }); + await page.locator("#create-key-name").fill("Reduced motion check"); + expect(await page.locator("#create-key-name").inputValue()).toBe("Reduced motion check"); + }); + + await step("Restore normal motion without losing the form", async () => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + expect(await page.locator("#create-key-name").inputValue()).toBe("Reduced motion check"); + const duration = await page + .getByRole("dialog") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)); + expect(duration).toBeGreaterThan(0.00001); + }); + }); + }), +); diff --git a/e2e/scenarios/support/search-invoke.ts b/e2e/scenarios/support/search-invoke.ts new file mode 100644 index 0000000000..0de674d34f --- /dev/null +++ b/e2e/scenarios/support/search-invoke.ts @@ -0,0 +1,24 @@ +import { Schema } from "effect"; +import { ToolAnnotationsView } from "@executor-js/sdk"; + +/** Parse the public search result, including schemas and account identity. */ +export const decodeToolSearch = Schema.decodeUnknownSync( + Schema.Struct({ + structuredContent: Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + integration: Schema.String, + owner: Schema.String, + connection: Schema.String, + inputSchema: Schema.Record(Schema.String, Schema.Unknown), + annotations: Schema.optional(ToolAnnotationsView), + }), + ), + total: Schema.Number, + hasMore: Schema.Boolean, + nextOffset: Schema.NullOr(Schema.Number), + }), + }), +); diff --git a/e2e/scenarios/tool-annotations.test.ts b/e2e/scenarios/tool-annotations.test.ts new file mode 100644 index 0000000000..398583db7f --- /dev/null +++ b/e2e/scenarios/tool-annotations.test.ts @@ -0,0 +1,44 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { ToolAddress } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); +const annotations = Schema.Struct({ requiresApproval: Schema.Boolean }); +const descriptions = Schema.Struct({ + gated: Schema.Struct({ annotations }), + plain: Schema.Record(Schema.String, Schema.Unknown), +}); + +scenario( + "Tool discovery · declared approval annotations survive API and sandbox descriptions", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + const address = ToolAddress.make("executor.coreTools.connections.create"); + const view = yield* client.tools.schema({ query: { address } }); + expect(view.annotations).toEqual({ requiresApproval: true }); + const described = yield* session.call("execute", { + code: ` + return JSON.stringify({ + gated: await tools.describe.tool({ path: "executor.coreTools.connections.create" }), + plain: await tools.describe.tool({ path: "executor.coreTools.connections.list" }), + }); + `, + }); + expect(described.ok, described.text).toBe(true); + const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(descriptions))( + described.text, + ); + expect(result.gated.annotations).toEqual(view.annotations); + expect(result.plain).not.toHaveProperty("annotations"); + }), +); diff --git a/e2e/scenarios/tool-call-contract.test.ts b/e2e/scenarios/tool-call-contract.test.ts index 7cb5394e3b..b693e50602 100644 --- a/e2e/scenarios/tool-call-contract.test.ts +++ b/e2e/scenarios/tool-call-contract.test.ts @@ -21,7 +21,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { @@ -38,6 +38,13 @@ import type { McpSession } from "../src/surfaces/mcp"; const api = composePluginApi([openApiHttpPlugin()] as const); +const completion = Schema.Struct({ + structuredContent: Schema.Struct({ + status: Schema.String, + toolName: Schema.optional(Schema.String), + }), +}); + const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; type UpstreamHandle = { @@ -114,14 +121,14 @@ const executeApproved = (session: McpSession, code: string) => guard += 1; } expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); - return result.text; + return result; }); /** Invoke a dynamic tool by full address and parse the envelope it returns. */ const invokeEnvelope = (session: McpSession, address: string, args: unknown = {}) => Effect.map( executeApproved(session, invokeByAddressCode(address, args)), - (text) => JSON.parse(text) as ToolEnvelope, + (result) => JSON.parse(result.text) as ToolEnvelope, ); // --------------------------------------------------------------------------- @@ -136,6 +143,20 @@ const widgetsSpec = (baseUrl: string): string => info: { title: "Widgets API", version: "1.0.0" }, servers: [{ url: baseUrl }], paths: { + "/unavailable": { + get: { + operationId: "unavailableWidgets", + summary: "Unavailable widgets", + responses: { "200": { description: "widgets" } }, + }, + }, + "/widgets/count": { + get: { + operationId: "countWidgets", + summary: "Count widgets", + responses: { "200": { description: "widget count" } }, + }, + }, "/widgets": { get: { operationId: "listWidgets", @@ -184,7 +205,7 @@ scenario( }, }); const created = JSON.parse( - yield* executeApproved(session, createConnectionCode(slug)), + (yield* executeApproved(session, createConnectionCode(slug))).text, ) as ToolEnvelope; expect(created.ok, `the no-auth connection was created: ${JSON.stringify(created)}`).toBe( true, @@ -199,7 +220,8 @@ scenario( const path = address!.replace(/^tools\./, ""); // 1. A well-addressed call executes and carries the upstream's payload. - const success = yield* invokeEnvelope(session, address!); + const successfulCall = yield* executeApproved(session, invokeByAddressCode(address!, {})); + const success = JSON.parse(successfulCall.text) as ToolEnvelope; expect( success.ok, `the call succeeded (got: ${JSON.stringify(success.error ?? {}).slice(0, 400)})`, @@ -207,7 +229,73 @@ scenario( expect(JSON.stringify(success.data), "the upstream's payload comes back").toContain( "anvil", ); + const structured = (successfulCall.raw as { readonly structuredContent?: unknown }) + .structuredContent; + expect(structured, "the completed MCP result includes structured content").toMatchObject({ + status: "completed", + toolName: path, + }); + expect(structured, "the internal tool-call trace is not exposed").not.toHaveProperty( + "toolPaths", + ); expect(upstream.requests(), "the upstream served exactly one call").toBe(1); + const discovery = yield* executeApproved( + session, + 'return await tools.search({ query: "widgets" });', + ); + const discoveryResult = yield* Schema.decodeUnknownEffect(completion)(discovery.raw); + expect(discoveryResult.structuredContent.toolName).toBeUndefined(); + expect(discovery.raw).not.toHaveProperty("structuredContent.toolPaths"); + + const failed = yield* executeApproved( + session, + invokeByAddressCode(address!.replace(/listWidgets$/, "missingWidget"), {}), + ); + const failedResult = yield* Schema.decodeUnknownEffect(completion)(failed.raw); + expect(failedResult.structuredContent.toolName).toBeUndefined(); + expect(failed.raw).not.toHaveProperty("structuredContent.toolPaths"); + const unavailableAddress = yield* Schema.decodeUnknownEffect(Schema.String)( + tools + .filter((tool) => String(tool.integration) === slug) + .map((tool) => String(tool.address)) + .find((candidate) => candidate.endsWith("unavailableWidgets")), + ); + const upstreamFailure = yield* executeApproved( + session, + invokeByAddressCode(unavailableAddress, {}), + ); + const upstreamFailureResult = yield* Schema.decodeUnknownEffect(completion)( + upstreamFailure.raw, + ); + expect(upstreamFailure.text).toContain('"ok":false'); + expect(upstreamFailureResult.structuredContent.toolName).toBeUndefined(); + expect(upstreamFailure.raw).not.toHaveProperty("structuredContent.toolPaths"); + + const repeated = yield* executeApproved( + session, + ` + await tools[${JSON.stringify(path)}]({}); + return await tools[${JSON.stringify(path)}]({}); + `, + ); + const repeatedResult = yield* Schema.decodeUnknownEffect(completion)(repeated.raw); + expect(repeatedResult.structuredContent.toolName).toBe(path); + expect(repeated.raw).not.toHaveProperty("structuredContent.toolPaths"); + const anotherAddress = tools + .filter((tool) => String(tool.integration) === slug) + .map((tool) => String(tool.address)) + .find((candidate) => candidate.endsWith("countWidgets")); + const anotherPath = yield* Schema.decodeUnknownEffect(Schema.String)(anotherAddress); + const multiple = yield* executeApproved( + session, + ` + await tools[${JSON.stringify(path)}]({}); + return await tools[${JSON.stringify(anotherPath.replace(/^tools\./, ""))}]({}); + `, + ); + const multipleResult = yield* Schema.decodeUnknownEffect(completion)(multiple.raw); + expect(multipleResult.structuredContent.toolName).toBeUndefined(); + expect(multiple.raw).not.toHaveProperty("structuredContent.toolPaths"); // 2a. A wrong TOOL name on a live connection: tool_not_found, and the // suggestions name the connection's real tools so the agent can @@ -257,7 +345,9 @@ scenario( "the defect mask never surfaces for a missing connection", ).not.toContain("Internal tool error"); - expect(upstream.requests(), "no misaddressed call ever reached the upstream").toBe(1); + expect(upstream.requests(), "only the five successful calls reached the upstream").toBe( + 5, + ); }), // Selfhost shares one workspace identity — leaked resources fail other // scenarios' zero-state assertions. diff --git a/e2e/selfhost/mcp-elicitation-deadline.test.ts b/e2e/selfhost/mcp-elicitation-deadline.test.ts new file mode 100644 index 0000000000..eaf925554a --- /dev/null +++ b/e2e/selfhost/mcp-elicitation-deadline.test.ts @@ -0,0 +1,86 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); +const decodeExecutionId = Schema.decodeUnknownSync(Schema.String); + +scenario( + "MCP · delayed approval preserves the chosen lifetime beyond the active-work deadline", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`deadline_${randomBytes(4).toString("hex")}`); + const server = yield* serveMcpServer(() => { + const upstream = new McpServer({ name: "Human approval", version: "1" }); + upstream.registerTool("approve", { inputSchema: {} }, async () => { + const reply = await upstream.server.elicitInput( + { + mode: "form", + message: "Approve the delayed call?", + requestedSchema: { type: "object", properties: {} }, + _meta: { persist: ["session", "always"] }, + }, + { timeout: 150_000 }, + ); + return { + content: [ + { type: "text", text: `decision:${reply.action}:${reply._meta?.persist ?? "once"}` }, + ], + }; + }); + return upstream; + }); + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Human approval", + endpoint: server.url, + slug, + remoteTransport: "streamable-http", + }, + }); + yield* Effect.gen(function* () { + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("none"), + value: "", + }, + }); + const session = mcp.session(identity, { elicitationMode: "model" }); + yield* session.listTools(); + const paused = yield* session.call("execute", { + code: `return await tools.${slug}.org.main.approve({});`, + }); + expect(paused.text).toContain("executionId:"); + // Cross the production 60-second active-work deadline. This is the + // behavior under test: a human waiting must consume none of that budget. + yield* Effect.sleep("65 seconds"); + const executionId = decodeExecutionId(/\bexecutionId:\s*(\S+)/.exec(paused.text)?.[1]); + const completed = yield* session.call("resume", { + executionId, + action: "accept", + persist: "session", + }); + expect(completed.ok).toBe(true); + expect(completed.text).toContain("decision:accept:session"); + }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie))); + }), + ), +); diff --git a/e2e/selfhost/mcp-oauth-callback-background-sync.test.ts b/e2e/selfhost/mcp-oauth-callback-background-sync.test.ts new file mode 100644 index 0000000000..bcc45dacfe --- /dev/null +++ b/e2e/selfhost/mcp-oauth-callback-background-sync.test.ts @@ -0,0 +1,170 @@ +// An OAuth callback commits the fresh grant before it synchronizes a remote +// MCP catalog. A slow tools/list response must not keep the popup request open; +// the host keeps catalog work alive and the tools converge afterward. +import { randomBytes } from "node:crypto"; + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { expect } from "@effect/vitest"; +import { Effect, Schedule } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { deriveMcpNamespace } from "@executor-js/plugin-mcp"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; +import { OAuthTestServer } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +const submitProviderLogin = async (loginUrl: string): Promise => { + const response = await fetch(loginUrl, { + method: "POST", + redirect: "manual", + headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` }, + }); + const location = response.headers.get("location"); + if (response.status !== 302 || !location) { + throw new Error(`provider login did not redirect (${response.status})`); + } + return new URL(location, loginUrl).toString(); +}; + +for (const failsFirst of [false, true]) { + scenario( + `MCP OAuth · callback closes before ${failsFirst ? "failing" : "blocked"} catalog discovery and preserves the grant`, + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const gate = Promise.withResolvers(); + const listing = Promise.withResolvers(); + let failListing = failsFirst; + const server = yield* serveMcpServerWithOAuth( + () => { + const mcp = new McpServer( + { name: "callback-mcp", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + mcp.server.setRequestHandler(ListToolsRequestSchema, async () => { + listing.resolve(); + if (failListing) throw new Error("Temporary catalog outage"); + return { tools: [{ name: "simple_echo", inputSchema: { type: "object" as const } }] }; + }); + return mcp; + }, + { path: "/mcp", beforeAuthenticatedRequest: () => gate.promise }, + ); + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const displayName = `Slow callback MCP ${randomBytes(3).toString("hex")}`; + const slug = IntegrationSlug.make(deriveMcpNamespace({ name: displayName })); + const clientsBefore = new Set((yield* client.oauth.listClients()).map((item) => item.slug)); + + yield* Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + await step("Add an OAuth-protected MCP integration", async () => { + const addUrl = new URL("/integrations/add/mcp", target.baseUrl); + addUrl.searchParams.set("url", server.endpoint); + await visit(page, addUrl.toString()); + await page + .getByText("How does this server authenticate?") + .waitFor({ timeout: 30_000 }); + await page.getByPlaceholder("e.g. Linear").fill(displayName); + await page.getByRole("button", { name: "Add integration" }).click(); + await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); + }); + + await step("Authorize while the MCP catalog is deliberately slow", async () => { + await page.getByRole("button", { name: "Add connection" }).first().click(); + await page.getByRole("heading", { name: /Add connection/ }).waitFor(); + + const popupPromise = page.waitForEvent("popup", { timeout: 30_000 }); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + const popup = await popupPromise; + await popup.waitForURL(/\/login\?/, { timeout: 30_000 }); + const callbackUrl = await submitProviderLogin(popup.url()); + + // No authenticated MCP request can complete before we release + // the gate. Callback success therefore proves ordering without + // racing a timer against a cold browser or a loaded host. + await popup.goto(callbackUrl, { waitUntil: "domcontentloaded", timeout: 30_000 }); + const committed = await Effect.runPromise( + client.connections.list({ query: { integration: slug } }), + ); + expect( + committed.length, + "the callback persisted the connection before discovery", + ).toBe(1); + // Release before the opener's separate health probe, which also + // uses this transport, after proving the callback has returned. + gate.resolve(); + await page + .getByText("Connection added", { exact: true }) + .waitFor({ timeout: 30_000 }); + }); + }); + + yield* Effect.promise(() => listing.promise); + const afterListing = yield* client.connections.list({ query: { integration: slug } }); + expect( + afterListing.length, + "a failed remote listing cannot remove the durable grant", + ).toBe(1); + const connection = afterListing[0]; + if (connection === undefined) return yield* Effect.die("Missing committed connection"); + expect( + connection.lastHealth?.status, + "discovery reports the actual upstream health", + ).toBe(failsFirst ? "degraded" : "healthy"); + failListing = false; + if (failsFirst) { + // Failed discovery intentionally backs off until the catalog TTL. + // An explicit refresh is the supported immediate recovery action. + const params = { owner: connection.owner, integration: slug, name: connection.name }; + yield* client.connections.refresh({ params }); + } + + const tools = yield* client.tools.list({ query: { integration: slug } }).pipe( + Effect.filterOrFail( + (items) => items.some((tool) => String(tool.name) === "simple_echo"), + () => "slow_mcp_catalog_pending" as const, + ), + Effect.retry(Schedule.both(Schedule.spaced("1 second"), Schedule.recurs(20))), + ); + expect( + tools.map((tool) => String(tool.name)), + "the host-kept background sync eventually publishes the remote tool", + ).toContain("simple_echo"); + const healthy = yield* client.connections.checkHealth({ + params: { owner: connection.owner, integration: slug, name: connection.name }, + query: {}, + }); + expect(healthy.status, "the recovered server accepts the existing grant").toBe("healthy"); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + gate.resolve(); + const clientsAfter = yield* client.oauth.listClients(); + for (const oauthClient of clientsAfter) { + if (!clientsBefore.has(oauthClient.slug)) { + yield* client.oauth.removeClient({ + params: { slug: oauthClient.slug }, + payload: { owner: oauthClient.owner }, + }); + } + } + yield* client.mcp.removeServer({ params: { slug } }); + }).pipe(Effect.ignore), + ), + ); + }), + ).pipe(Effect.provide(OAuthTestServer.layer())), + ); +} diff --git a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts index 94f9d5fa1c..dfb6827121 100644 --- a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts +++ b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts @@ -20,7 +20,7 @@ import { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); scenario( - "MCP OAuth · advertised CIMD starts authorization without dynamic registration", + "MCP OAuth · CIMD advertises refresh support and completes connection without dynamic registration", { timeout: 180_000 }, Effect.scoped( Effect.gen(function* () { @@ -30,7 +30,7 @@ scenario( const oauth = yield* OAuthTestServer; const server = yield* serveMcpServerWithOAuth( () => makeGreetingMcpServer({ name: "cimd-connect-mcp" }), - { path: "/mcp" }, + { path: "/mcp", scopes: ["read", "offline_access"] }, ); const identity = yield* target.newIdentity(); const client = yield* makeApiClient(api, identity); @@ -69,17 +69,68 @@ scenario( authorize, "the popup reached the discovered authorization endpoint", ).toBeDefined(); - const clientId = authorize?.query["client_id"]; - createdClientId = clientId; + expect( + (authorize?.query["scope"] ?? "").split(" "), + "authorization requests the resource's offline access scope", + ).toContain("offline_access"); + const clientId = authorize?.query["client_id"] ?? ""; + createdClientId = clientId || undefined; expect( clientId, "authorization uses Executor's metadata document as client_id", ).toMatch(/^https?:\/\/[^/]+\/api\/oauth\/client-id-metadata\/.+\.json$/); - await popup.close(); + const metadataResponse = await page.request.get(clientId); + expect(metadataResponse.status(), "the client metadata document is reachable").toBe( + 200, + ); + expect( + await metadataResponse.json(), + "the client declares the grant required by offline_access", + ).toMatchObject({ + grant_types: ["authorization_code", "refresh_token"], + }); + expect(authorize).toBeDefined(); + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: authorization must exist before completing the flow + if (authorize === undefined) throw new Error("Missing authorization request"); + const completed = await Effect.runPromise( + oauth.completeAuthorizationCodeFlow({ authorizationUrl: authorize.url }), + ); + await popup.goto(completed.callbackUrl); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden" }); + await popup.close().catch(() => undefined); }); }); + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect(connections, "the OAuth callback saved the connection").toHaveLength(1); + const tools = yield* client.tools.list({ query: { integration: slug } }); + expect( + tools.some((tool) => tool.name === "simple_echo"), + "authenticated discovery finds the upstream tool", + ).toBe(true); + + const invoked = yield* client.executions.execute({ + payload: { + code: `return await ${tools[0]?.address}({});`, + autoApprove: true, + }, + }); + expect(invoked.status).toBe("completed"); + expect(invoked.text, "the connected tool runs through authenticated MCP").toContain( + "mcp-ok", + ); + const requests = yield* oauth.requests; + expect( + requests.some( + (request) => + request.path === "/token" && + new URLSearchParams(request.body).get("grant_type") === "authorization_code", + ), + "the callback exchanged the code using the advertised client", + ).toBe(true); expect( requests.filter((request) => request.method === "POST" && request.path === "/register"), "CIMD wins when the server also advertises DCR", @@ -105,5 +156,12 @@ scenario( ), ); }), - ).pipe(Effect.provide(OAuthTestServer.layer({ clientIdMetadataDocumentSupported: true }))), + ).pipe( + Effect.provide( + OAuthTestServer.layer({ + clientIdMetadataDocumentSupported: true, + scopes: ["read", "offline_access"], + }), + ), + ), ); diff --git a/e2e/selfhost/mcp-session-resource-cleanup.test.ts b/e2e/selfhost/mcp-session-resource-cleanup.test.ts new file mode 100644 index 0000000000..9056388480 --- /dev/null +++ b/e2e/selfhost/mcp-session-resource-cleanup.test.ts @@ -0,0 +1,85 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "MCP · deleting a host session closes its upstream connection", + { timeout: 120_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const apiClient = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`session_cleanup_${randomBytes(4).toString("hex")}`); + const upstream = yield* serveMcpServer(makeGreetingMcpServer); + yield* apiClient.mcp.addServer({ + payload: { + transport: "remote", + name: "Session cleanup", + endpoint: upstream.url, + slug, + remoteTransport: "streamable-http", + }, + }); + yield* Effect.gen(function* () { + yield* apiClient.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("none"), + value: "", + }, + }); + yield* Effect.promise(() => expect.poll(upstream.inFlightRequests).toBe(0)); + const transport = new StreamableHTTPClientTransport(new URL(target.mcpUrl), { + requestInit: { headers: identity.headers }, + }); + const client = yield* Effect.acquireRelease( + Effect.promise(async () => { + const connected = new Client({ name: "session-cleanup-e2e", version: "1" }); + await connected.connect(transport); + return connected; + }), + (connected) => Effect.promise(() => connected.close()), + ); + const result = yield* Effect.promise(() => + client.callTool({ + name: "execute", + arguments: { code: `return await tools.${slug}.org.main.simple_echo({});` }, + }), + ); + expect(result.isError).toBeFalsy(); + expect(JSON.stringify(result.content)).toContain("mcp-ok"); + yield* Effect.promise(() => + expect + .poll(upstream.inFlightRequests, { + message: "the tool leaves its upstream event stream open for reuse", + }) + .toBeGreaterThan(0), + ); + yield* Effect.promise(() => transport.terminateSession()); + yield* Effect.promise(() => + expect + .poll(upstream.inFlightRequests, { + timeout: 10_000, + message: "closing the host session releases its upstream event stream", + }) + .toBe(0), + ); + }).pipe(Effect.ensuring(apiClient.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie))); + }), + ), +); diff --git a/e2e/selfhost/oauth-optional-scopes.test.ts b/e2e/selfhost/oauth-optional-scopes.test.ts new file mode 100644 index 0000000000..b14dac1274 --- /dev/null +++ b/e2e/selfhost/oauth-optional-scopes.test.ts @@ -0,0 +1,172 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { connectEmulator } from "@executor-js/emulate"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +scenario( + "OAuth optional scopes · the integration partitions scopes and completes an authenticated connection", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + expect((yield* session.call("execute", { code: "return true;" })).ok).toBe(true); + const base = yield* createEmulatorInstance("github", "optional-scopes"); + const emulator = yield* Effect.promise(() => + connectEmulator({ baseUrl: base, service: "github" }), + ); + yield* Effect.promise(() => emulator.seed({ users: [{ login: "optional-scope-user" }] })); + const slug = IntegrationSlug.make(`optional-${randomBytes(4).toString("hex")}`); + const app = OAuthClientSlug.make(`${slug}-app`); + yield* Effect.addFinalizer(() => + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.orDie), + ); + yield* Effect.addFinalizer(() => + client.oauth + .removeClient({ params: { slug: app }, payload: { owner: "org" } }) + .pipe(Effect.orDie), + ); + // GitHub supplies the real OAuth transport and protected resource. The test + // verifies Executor's partitioning contract; it does not claim that GitHub + // implements HubSpot's optional-grant policy. + const authorizationUrl = `${base}/login/oauth/authorize`; + const tokenUrl = `${base}/login/oauth/access_token`; + yield* client.openapi.addSpec({ + payload: { + slug, + baseUrl: base, + spec: { + kind: "blob", + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "Optional scope API", version: "1" }, + paths: { + "/user": { + get: { + operationId: "getUser", + security: [{ oauth: ["read:user"] }], + responses: { "200": { description: "Authenticated user" } }, + }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl, + tokenUrl, + scopes: { + "read:user": "Read user", + "user:email": "Read email when granted", + }, + }, + }, + }, + }, + }, + }), + }, + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2", + authorizationUrl: `${authorizationUrl}?optional_scope=user%3Aemail`, + tokenUrl, + scopes: ["read:user", "user:email"], + }, + ], + }, + }); + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: app, + grant: "authorization_code", + authorizationUrl, + tokenUrl, + clientId: "optional-test-client", + clientSecret: "optional-test-secret", + originIntegration: slug, + }, + }); + const started = yield* client.oauth.start({ + payload: { + owner: "org", + client: app, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("oauth"), + }, + }); + if (started.status !== "redirect") + return yield* Effect.die("Expected authorization redirect"); + + yield* Effect.addFinalizer(() => + client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.orDie), + ); + const url = new URL(started.authorizationUrl); + expect(url.searchParams.get("scope")).toBe("read:user"); + expect(url.searchParams.get("optional_scope")).toBe("user:email"); + yield* browser.session(identity, async ({ page, step }) => { + await step("Review and approve the OAuth consent request", async () => { + await page.goto(started.authorizationUrl); + await page.getByRole("button", { name: /optional-scope-user/ }).click(); + await page.getByText("Connected", { exact: true }).waitFor({ timeout: 30_000 }); + }); + }); + yield* Effect.addFinalizer(() => + client.connections + .remove({ + params: { owner: "org", integration: slug, name: ConnectionName.make("main") }, + }) + .pipe(Effect.orDie), + ); + const catalog = yield* client.tools.list({ query: { integration: slug } }); + const tool = catalog.find((entry) => entry.name.endsWith("getUser")); + if (!tool) + return yield* Effect.die( + `Authenticated getUser tool missing: ${catalog.map((entry) => entry.name).join(", ")}`, + ); + let result = yield* session.call("execute", { + code: `const path = ${JSON.stringify(String(tool.address))}.split(".").slice(1); let call = tools; for (const part of path) call = call[part]; return await call({});`, + }); + for (let attempts = 0; result.text.includes("executionId:") && attempts < 10; attempts += 1) + result = yield* session.approvePaused(result.text); + expect(result.ok).toBe(true); + expect(result.text).toContain("optional-scope-user"); + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + const authorize = ledger.find( + (entry) => entry.method === "GET" && entry.path.endsWith("/login/oauth/authorize"), + ); + expect(new URLSearchParams(authorize?.query).get("optional_scope")).toBe("user:email"); + expect( + ledger + .filter((entry) => entry.path === "/user" && entry.response.status === 200) + .map((entry) => entry.identity.user?.login), + ).toContain("optional-scope-user"); + }), + ), +); diff --git a/e2e/selfhost/vercel-oauth-lifecycle.test.ts b/e2e/selfhost/vercel-oauth-lifecycle.test.ts new file mode 100644 index 0000000000..cc02af906f --- /dev/null +++ b/e2e/selfhost/vercel-oauth-lifecycle.test.ts @@ -0,0 +1,146 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { connectEmulator } from "@executor-js/emulate"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "Vercel OAuth · lifecycle scopes survive registration and a complete MCP connection", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + // Authenticate the host MCP transport before minting upstream credentials. + // The hosted emulator currently loses its OAuth token map on eviction. + const ready = yield* session.call("execute", { code: "return true;" }); + expect(ready.ok).toBe(true); + const base = yield* createEmulatorInstance("mcp", "vercel-lifecycle"); + const emulator = yield* Effect.promise(() => + connectEmulator({ baseUrl: base, service: "mcp" }), + ); + yield* Effect.promise(() => + emulator.seed({ users: [{ login: "lifecycle-user" }], scopes: ["openid"] }), + ); + const slug = IntegrationSlug.make(`lifecycle-${randomBytes(4).toString("hex")}`); + const app = OAuthClientSlug.make(`${slug}-app`); + yield* Effect.addFinalizer(() => + client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore), + ); + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Lifecycle MCP", + endpoint: `${base}/mcp`, + slug, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + const registered = yield* client.oauth.registerDynamic({ + payload: { + owner: "org", + slug: app, + issuer: base, + registrationEndpoint: `${base}/register`, + authorizationUrl: "https://vercel.com/oauth/authorize", + tokenUrl: `${base}/token`, + resource: `${base}/mcp`, + scopes: ["openid"], + tokenEndpointAuthMethodsSupported: ["none"], + originIntegration: slug, + }, + }); + yield* Effect.addFinalizer(() => + client.oauth + .removeClient({ params: { slug: registered.client }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ); + const started = yield* client.oauth.start({ + payload: { + owner: "org", + client: registered.client, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("oauth2"), + }, + }); + if (started.status !== "redirect") + return yield* Effect.die("Expected authorization redirect"); + yield* Effect.addFinalizer(() => + client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore), + ); + const authorize = new URL(started.authorizationUrl); + expect(authorize.origin + authorize.pathname).toBe("https://vercel.com/oauth/authorize"); + expect(authorize.searchParams.get("scope")?.split(" ")).toEqual(["openid", "offline_access"]); + // Route only the provider transport to the published emulator. The product + // produced all OAuth parameters; DCR, PKCE, consent, callback and MCP are real. + // This fixture does not claim to prove Vercel's refresh-token issuance policy. + const consentUrl = `${base}/authorize${authorize.search}`; + yield* browser.session(identity, async ({ page, step }) => { + await step("Review the provider consent request", async () => { + await page.goto(consentUrl); + await page.getByText("Authorize MCP client", { exact: true }).waitFor(); + }); + await step("Approve the requested access and complete the callback", async () => { + const form = Object.fromEntries(authorize.searchParams); + const approved = await page.request.post(`${base}/authorize/approve`, { + form: { ...form, login: "lifecycle-user" }, + maxRedirects: 0, + }); + expect(approved.status()).toBe(302); + const callback = approved.headers().location; + expect(callback).toBeDefined(); + await page.goto(callback!); + await page + .getByText(/connected|complete|success/i) + .first() + .waitFor({ timeout: 30_000 }); + }); + }); + const tools = yield* client.tools.list({ query: { integration: slug } }); + const tool = tools.find((entry) => entry.name === "get_me"); + expect(tool).toBeDefined(); + if (!tool) return yield* Effect.die("Connected MCP has no get_me tool"); + let result = yield* session.call("execute", { + code: `const path = ${JSON.stringify(String(tool.address))}.split(".").slice(1); let call = tools; for (const part of path) call = call[part]; return await call({});`, + }); + for (let attempts = 0; result.text.includes("executionId:") && attempts < 10; attempts += 1) + result = yield* session.approvePaused(result.text); + expect(result.ok).toBe(true); + expect(result.text).toContain("lifecycle-user"); + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + const registration = ledger.find( + (entry) => entry.method === "POST" && entry.path.endsWith("/register"), + ); + expect(registration?.request.body).toMatchObject({ scope: "openid offline_access" }); + expect( + ledger.some( + (entry) => + entry.method === "POST" && + entry.path.endsWith("/token") && + entry.response.status === 200, + ), + ).toBe(true); + }), + ), +); diff --git a/e2e/src/surfaces/autumn.ts b/e2e/src/surfaces/autumn.ts index 05a99bafd7..23f8ed4a3e 100644 --- a/e2e/src/surfaces/autumn.ts +++ b/e2e/src/surfaces/autumn.ts @@ -78,6 +78,21 @@ export interface AutumnSurface { * the billing backend becomes consistent. The `sessionId` is the last path * segment of the hosted checkout URL the browser was sent to. */ readonly settleCheckout: (sessionId: string) => Effect.Effect; + /** Land the asynchronous webhook for a hosted card-update (setup) session, + * replacing the customer's default payment method. Same race as + * `settleCheckout`: the browser is redirected back first, the card only + * changes once this is called. The `sessionId` is the last path segment of + * the hosted setup URL the browser was sent to. */ + readonly settleSetup: (sessionId: string) => Effect.Effect; + /** The customer's default payment method as Autumn holds it (the + * `payment_method` expand on `customers.get_or_create`), or null when no + * card is on file. */ + readonly paymentMethod: ( + customerId: string, + ) => Effect.Effect< + { brand: string; last4: string; expMonth: number; expYear: number } | null, + unknown + >; /** Burn an org's entire remaining "executions" balance in one `balances.track`, * so the next `balances.check` reports `allowed: false`. The amount is the * default plan's included allotment (read from the plan seed, never hardcoded), @@ -182,6 +197,50 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { } }); + const settleSetup = (sessionId: string) => + Effect.gen(function* () { + const response = yield* Effect.promise(() => + fetch(`${autumnUrl}/checkout/setup/${encodeURIComponent(sessionId)}/settle`, { + method: "POST", + }), + ); + if (!response.ok) { + return yield* Effect.fail( + `autumn setup settle responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, + ); + } + }); + + const paymentMethod = (customerId: string) => + Effect.gen(function* () { + const response = yield* Effect.promise(() => + fetch(`${autumnUrl}/v1/customers.get_or_create`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ customer_id: customerId, expand: ["payment_method"] }), + }), + ); + if (!response.ok) { + return yield* Effect.fail( + `autumn customers.get_or_create responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, + ); + } + const body = (yield* Effect.promise(() => response.json())) as { + readonly payment_method?: { + readonly card?: { + readonly brand: string; + readonly last4: string; + readonly exp_month: number; + readonly exp_year: number; + }; + } | null; + }; + const card = body.payment_method?.card; + return card + ? { brand: card.brand, last4: card.last4, expMonth: card.exp_month, expYear: card.exp_year } + : null; + }); + // Track exactly the plan's included allotment in one event, driving // `remaining` (included - usage) to zero so the next check reports blocked. const exhaustExecutions = (customerId: string) => @@ -328,6 +387,8 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { usageEvents, customerIds, settleCheckout, + settleSetup, + paymentMethod, exhaustExecutions, attachPlan, armFault, diff --git a/e2e/src/surfaces/mcp.ts b/e2e/src/surfaces/mcp.ts index e1fa12c5f1..8e01f29258 100644 --- a/e2e/src/surfaces/mcp.ts +++ b/e2e/src/surfaces/mcp.ts @@ -100,6 +100,11 @@ export interface McpCallResult { export interface McpToolDef { readonly name: string; readonly description: string; + /** The advertised MCP annotations (`readOnlyHint`, `destructiveHint`, …), + * for scenarios that assert on what a harness's native approval reads. */ + readonly annotations?: Record; + /** The advertised input JSON Schema, verbatim. */ + readonly inputSchema?: unknown; } /** How a connection surfaces a paused (approval-gated) execution. `browser` is @@ -172,6 +177,9 @@ export interface McpSurface { * `search_` tools (`?search_tools=true`). Omitted means * the product default: none. */ readonly searchTools?: boolean; + /** `passthrough` serves search and invoke for the visible catalog + * (`?mode=passthrough`). Omitted means the product default: codemode. */ + readonly mode?: "codemode" | "passthrough"; readonly url?: string; }, ) => McpSession; @@ -315,6 +323,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( ...(options?.elicitationMode ? [`elicitation_mode=${options.elicitationMode}`] : []), ...(options?.artifacts === false ? ["artifacts=false"] : []), ...(options?.searchTools === true ? ["search_tools=true"] : []), + ...(options?.mode === "passthrough" ? ["mode=passthrough"] : []), ].join("&"); const sessionUrl = sessionQuery ? `${mcpUrl}?${sessionQuery}` : mcpUrl; @@ -358,6 +367,10 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( return listed.tools.map((tool) => ({ name: tool.name, description: tool.description ?? "", + ...(tool.annotations + ? { annotations: tool.annotations as Record } + : {}), + inputSchema: tool.inputSchema, })); }), call, @@ -408,14 +421,37 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( return defs.map((tool: { name: string }) => tool.name); }); + // mcporter's `listTools` projects annotations away, so read the raw + // client it holds: the same connection (and cached OAuth), the full tool + // definition. const describeTools = () => Effect.promise(async (): Promise> => { - const defs = await (await runtime()).listTools(serverName, callOptions); - connected = true; - return defs.map((tool: { name: string; description?: string }) => ({ - name: tool.name, - description: tool.description ?? "", - })); + const rt = await runtime(); + if (!connected) { + await rt.listTools(serverName, callOptions); + connected = true; + } + const context = await rt.connect(serverName, { + allowCachedAuth: true, + oauthSessionOptions: callOptions.oauthSessionOptions, + }); + const out: McpToolDef[] = []; + let cursor: string | undefined; + do { + const page = await context.client.listTools(cursor ? { cursor } : undefined); + for (const tool of page.tools) { + out.push({ + name: tool.name, + description: tool.description ?? "", + ...(tool.annotations + ? { annotations: tool.annotations as Record } + : {}), + inputSchema: tool.inputSchema, + }); + } + cursor = page.nextCursor ?? undefined; + } while (cursor); + return out; }); const call = (name: string, args: Record = {}) => diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 2ed8ba01bd..6a050c738a 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -48,6 +48,7 @@ export default defineConfig({ project("cloudflare", { include: [ "scenarios/browser-approval.test.ts", + "scenarios/mcp-approval-persistence.test.ts", "scenarios/microsoft-graph-full.test.ts", "scenarios/toolkits-mcp.test.ts", "cloudflare/**/*.test.ts", diff --git a/packages/core/api/src/executions/api.ts b/packages/core/api/src/executions/api.ts index a7d22d9690..8b5589f149 100644 --- a/packages/core/api/src/executions/api.ts +++ b/packages/core/api/src/executions/api.ts @@ -44,6 +44,10 @@ const ExecuteResponse = Schema.Union([CompletedResult, PausedResult]); const ResumeRequest = Schema.Struct({ action: Schema.Literals(["accept", "decline", "cancel"]), content: Schema.optional(Schema.Unknown), + /** How long an accepted approval lasts, when the paused interaction's + * terms offer a choice (`interaction.meta.persist` lists the scopes). + * Omitted, the approval is for this call only. */ + persist: Schema.optional(Schema.String), }); const ResumeResponse = Schema.Union([CompletedResult, PausedResult]); diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts index 67f77de650..a6c3f137dc 100644 --- a/packages/core/api/src/handlers/executions.ts +++ b/packages/core/api/src/handlers/executions.ts @@ -251,6 +251,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" engine.resume(path.executionId, { action: payload.action, content: payload.content as Record | undefined, + ...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }), }), ); diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 058ff4e8ab..eb4b1f939b 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -212,13 +212,16 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler const html = yield* runOAuthCallback({ complete: ({ state, code, callbackDomain }) => executor.oauth - .complete({ - // `runOAuthCallback`'s `state` is a raw string from the URL; - // the SDK speaks the branded `OAuthState` (nominal brand). - state: OAuthState.make(state), - code: code ?? "", - callbackDomain, - }) + .complete( + { + // `runOAuthCallback`'s `state` is a raw string from the URL; + // the SDK speaks the branded `OAuthState` (nominal brand). + state: OAuthState.make(state), + code: code ?? "", + callbackDomain, + }, + { toolSync: "background" }, + ) .pipe( Effect.tapError((cause: unknown) => Effect.logError("OAuth callback completion failed", cause), diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 2512436557..3891f6cae3 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -71,6 +71,8 @@ export const makeMcpBuildServer = engine, artifacts: executor.artifacts, connections: executor.connections, + tools: executor.tools, + integrations: executor.integrations, ...(hostOptions?.loadAppShellHtml ? { loadAppShellHtml: hostOptions.loadAppShellHtml } : {}), @@ -88,7 +90,9 @@ export const makeMcpBuildServer = ...(options ?? {}), }).pipe( Effect.withSpan("mcp.server.create"), - Effect.map((mcpServer) => ({ mcpServer, engine })), + // Catalog failures use the same retryable build envelope. + Effect.mapError((cause) => new McpEngineBuildError({ cause })), + Effect.map((mcpServer) => ({ mcpServer, engine, executor })), ), ), ); diff --git a/packages/core/api/src/server/oauth-client-metadata.test.ts b/packages/core/api/src/server/oauth-client-metadata.test.ts index fd730dabe7..384c0e3187 100644 --- a/packages/core/api/src/server/oauth-client-metadata.test.ts +++ b/packages/core/api/src/server/oauth-client-metadata.test.ts @@ -19,6 +19,7 @@ describe("OAuth client ID metadata document", () => { "http://100.81.219.45:42384/api/oauth/client-id-metadata/acme.json", ); expect(metadata.redirect_uris).toEqual(["http://100.81.219.45:42384/api/oauth/callback"]); + expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]); expect(metadata.token_endpoint_auth_method).toBe("none"); expect(metadata.application_type).toBe("web"); }); @@ -63,6 +64,7 @@ describe("OAuth client ID metadata document", () => { "http://localhost/api/oauth/callback", "http://[::1]/api/oauth/callback", ]); + expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]); expect(metadata.application_type).toBe("native"); }); diff --git a/packages/core/api/src/server/oauth-client-metadata.ts b/packages/core/api/src/server/oauth-client-metadata.ts index c597745d59..d2e30d729c 100644 --- a/packages/core/api/src/server/oauth-client-metadata.ts +++ b/packages/core/api/src/server/oauth-client-metadata.ts @@ -9,6 +9,10 @@ export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX = export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET = "default" as const; export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET = "local" as const; +// Keep CIMD aligned with DCR: providers may reject `offline_access` unless the +// client declares that it can use the refresh-token grant. +const OAUTH_CLIENT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const; + type MetadataTarget = | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET @@ -19,7 +23,7 @@ interface OAuthClientIdMetadataDocument { readonly client_name: string; readonly client_uri: string; readonly redirect_uris: readonly string[]; - readonly grant_types: readonly ["authorization_code"]; + readonly grant_types: typeof OAUTH_CLIENT_GRANT_TYPES; readonly response_types: readonly ["code"]; readonly token_endpoint_auth_method: "none"; readonly application_type: "web" | "native"; @@ -129,7 +133,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({ client_name: "Executor Local", client_uri: url.origin, redirect_uris: localLoopbackRedirectUris(mountPrefix), - grant_types: ["authorization_code"], + grant_types: OAUTH_CLIENT_GRANT_TYPES, response_types: ["code"], token_endpoint_auth_method: "none", application_type: "native", @@ -150,7 +154,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({ client_name: "Executor", client_uri: url.origin, redirect_uris: [redirectUri.toString()], - grant_types: ["authorization_code"], + grant_types: OAUTH_CLIENT_GRANT_TYPES, response_types: ["code"], token_endpoint_auth_method: "none", application_type: "web", diff --git a/packages/core/execution/src/engine.test.ts b/packages/core/execution/src/engine.test.ts index 06b9e20888..9cd3609072 100644 --- a/packages/core/execution/src/engine.test.ts +++ b/packages/core/execution/src/engine.test.ts @@ -279,6 +279,34 @@ describe("formatPausedExecution approval terms", () => { }); }); + it("says how to answer when the terms leave the approval's lifetime to the caller", () => { + // Computer Use's app approval: a bare accept is one-time and the same + // prompt returns on the next call, so the caller has to be told the + // scopes on offer and how to pick one. + const result = formatPausedExecution( + paused( + FormElicitation.make({ + message: 'Allow Computer Use to use "Finder"?', + requestedSchema: {}, + meta: { persist: ["session", "always"], connector_name: "Computer Use" }, + }), + ), + ); + + const interaction = result.structured["interaction"] as { + readonly meta?: unknown; + readonly instructions: string; + }; + expect(interaction.meta).toEqual({ + persist: ["session", "always"], + connector_name: "Computer Use", + }); + expect(interaction.instructions).toContain( + 'pass persist as one of "session", "always"; without it the approval is for this call only', + ); + expect(result.text).toContain(interaction.instructions); + }); + it("says nothing about terms when the upstream attached none", () => { const result = formatPausedExecution( paused(FormElicitation.make({ message: "Proceed?", requestedSchema: {} })), @@ -309,6 +337,35 @@ describe("formatExecuteResult output identity", () => { expect(formatted.isError).toBe(false); }); + it("returns the sole distinct connected tool name without exposing the call trace", () => { + const result = { + result: { issues: [] }, + logs: [], + toolPaths: ["linear.org.work.issues.list", "linear.org.work.issues.list"], + } as ExecuteResult & { readonly toolPaths: readonly string[] }; + + const formatted = formatExecuteResult(result); + + expect(formatted.structured).toEqual({ + status: "completed", + result: { issues: [] }, + toolName: "linear.org.work.issues.list", + logs: [], + }); + }); + + it("omits a tool name when distinct connected tools were used", () => { + const result = { + result: { issues: [], projects: [] }, + logs: [], + toolPaths: ["linear.org.work.issues.list", "linear.org.work.projects.list"], + } as ExecuteResult & { readonly toolPaths: readonly string[] }; + + const formatted = formatExecuteResult(result); + + expect(formatted.structured).not.toHaveProperty("toolName"); + }); + it("truncates a long preview with the exact suffix and untouched structured value", () => { const value = { data: "é🎉".repeat(12_000) }; const pretty = JSON.stringify(value, null, 2); diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 8bb9bda071..dad73e2025 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -6,10 +6,15 @@ import type { Executor, InvokeOptions, ElicitationResponse, + ElicitationResponseMeta, ElicitationHandler, ElicitationContext, } from "@executor-js/sdk/core"; -import { CurrentOrgWriteAccess, type OrgWriteAccessState } from "@executor-js/sdk/core"; +import { + CurrentOrgWriteAccess, + offeredPersistence, + type OrgWriteAccessState, +} from "@executor-js/sdk/core"; import { CodeExecutionError } from "@executor-js/codemode-core"; import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core"; @@ -58,6 +63,9 @@ type InternalPausedExecution = PausedExecution & { export type ResumeResponse = { readonly action: "accept" | "decline" | "cancel"; readonly content?: Record; + /** The answer's terms — `persist`, when the paused request offered a + * choice of scopes and the approver picked one. */ + readonly meta?: ElicitationResponseMeta; }; // Auto-accept every elicitation. Used by the `autoApprove` path where the @@ -138,6 +146,11 @@ const truncate = (value: string, max: number): string => ? `${value.slice(0, max)}\n... [truncated ${value.length - max} chars]` : value; +const soleConnectedToolName = (toolPaths: readonly string[] | undefined): string | undefined => { + const names = [...new Set(toolPaths ?? [])]; + return names.length === 1 ? names[0] : undefined; +}; + export const formatExecuteResult = ( result: ExecuteResult, ): { @@ -183,11 +196,13 @@ export const formatExecuteResult = ( ? `(no return value; ${emittedNote})` : "(no result)"; const parts = [resultPart, ...(logText ? [`\nLogs:\n${logText}`] : [])]; + const toolName = soleConnectedToolName(result.toolPaths); return { text: parts.join("\n"), structured: { status: "completed", result: result.result ?? null, + ...(toolName ? { toolName } : {}), ...emittedField, logs: result.logs ?? [], }, @@ -215,10 +230,21 @@ export const formatPausedExecution = ( : hasRequestedSchema ? `Ask the user for values matching requestedSchema. Then call the resume tool with executionId "${paused.id}", action "accept", and content matching requestedSchema. If the user declines, call resume with action "decline" or "cancel".` : `This is a model-side confirmation gate; there is no browser form to open. Ask the user whether to approve the paused tool call. If the user approves, call the resume tool with executionId "${paused.id}" and action "accept". If the user declines, call resume with action "decline" or "cancel".`; + // When the upstream leaves the LIFETIME of an accept to the answer, the + // caller has to know that a bare accept is a one-time approval — the same + // prompt returns on the next call — and how to say otherwise. + const meta = req.meta; + const offered = offeredPersistence(meta); + const persistInstructions = + offered.length > 0 + ? ` To have an accepted approval remembered, also pass persist as one of ${offered + .map((scope) => JSON.stringify(scope)) + .join(", ")}; without it the approval is for this call only.` + : ""; const deadlineInstructions = deadline ? ` Resume before ${deadline.expiresAt}; this approval window lasts ${formatTtlDuration(deadline.ttlMs)}.` : ""; - const instructions = `${baseInstructions}${deadlineInstructions}`; + const instructions = `${baseInstructions}${persistInstructions}${deadlineInstructions}`; if (isUrlElicitation) { lines.push(`\nOpen this URL in a browser:\n${req.url}`); @@ -237,7 +263,6 @@ export const formatPausedExecution = ( // Terms the upstream attached to the approval. Stated plainly, because a // prompt whose schema is empty ("Allow X to access Y?") can still be // asking for a PERSISTENT grant, and the answer differs. - const meta = req.meta; if (meta !== undefined && Object.keys(meta).length > 0) { lines.push(`\nApproval terms:\n${JSON.stringify(meta, null, 2)}`); } @@ -318,8 +343,9 @@ const makeFullInvoker = ( executor: Executor, invokeOptions: InvokeOptions, toolDiscoveryProvider: ToolDiscoveryProvider, + onConnectedToolCall?: (path: string) => void, ): SandboxToolInvoker => { - const base = makeExecutorToolInvoker(executor, { invokeOptions }); + const base = makeExecutorToolInvoker(executor, { invokeOptions, onConnectedToolCall }); return { invoke: ({ path, args }) => { if (path === "search") { @@ -694,13 +720,18 @@ export const createExecutionEngine = toolPaths.push(path), ); fiber = yield* Effect.forkDetach( - codeExecutor.execute(code, invoker).pipe(Effect.withSpan("executor.code.exec")), + codeExecutor.execute(code, invoker).pipe( + Effect.map((result) => (toolPaths.length === 0 ? result : { ...result, toolPaths })), + Effect.withSpan("executor.code.exec"), + ), ); liveSandboxFibers.add(fiber); @@ -798,6 +829,7 @@ export const createExecutionEngine = toolPaths.push(path), + ); + const result = yield* codeExecutor.execute(code, invoker).pipe( + Effect.map((result) => (toolPaths.length === 0 ? result : { ...result, toolPaths })), + Effect.withSpan("executor.code.exec"), ); - const result = yield* codeExecutor - .execute(code, invoker) - .pipe(Effect.withSpan("executor.code.exec")); yield* annotateExecuteOutcome(result); return result; }); diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 747bd23105..a2aa222d17 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -18,6 +18,7 @@ import { type AnyPlugin, type CredentialProvider, type Elicit, + type ToolAnnotations, type ToolDef, } from "@executor-js/sdk"; import { @@ -127,6 +128,7 @@ type TestToolSpec = { readonly outputJsonSchema?: unknown; /** Standard-schema validator applied to args in `invokeTool`. */ readonly validator?: Validator; + readonly annotations?: ToolAnnotations; readonly handler: (input: ToolHandlerInput) => Effect.Effect; }; @@ -160,6 +162,12 @@ const validateArgs = ( ); }; +const withPrivateAnnotations = (annotations: ToolAnnotations) => ({ + ...annotations, + upstreamToolName: "private-provider-tool", + _meta: { privateMarker: "not-public" }, +}); + const makeTestPlugin = (config: { readonly pluginId: string; readonly integration: string; @@ -179,6 +187,11 @@ const makeTestPlugin = (config: { description: spec.description, inputSchema: spec.inputJsonSchema, outputSchema: spec.outputJsonSchema, + ...(spec.annotations + ? { + annotations: withPrivateAnnotations(spec.annotations), + } + : {}), }), ), }), @@ -238,6 +251,11 @@ const crmPlugin = makeTestPlugin({ description: "Create a CRM contact record", inputJsonSchema: ContactInputJson, validator: ContactValidator, + annotations: { + requiresApproval: true, + approvalDescription: "Creates a contact record in the CRM", + mayElicit: false, + }, handler: () => Effect.succeed({ id: "contact_1" }), }, { @@ -674,6 +692,25 @@ describe("tool discovery", () => { }), ); + it.effect("records only the connected tool resolved after discovery", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + const engine = createExecutionEngine({ executor, codeExecutor }); + + const execution = yield* engine.execute( + [ + 'const search = await tools.search({ query: "repository details", namespace: "github", limit: 1 });', + 'const result = await tools[search.items[0].path]({ owner: "executor", repo: "executor" });', + "return result;", + ].join("\n"), + { onElicitation: acceptAll }, + ); + + expect(execution.error).toBeUndefined(); + expect(execution.toolPaths).toEqual(["github.org.main.getRepositoryDetails"]); + }), + ); + it.effect("lets execution hosts provide custom tool discovery", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); @@ -878,6 +915,22 @@ describe("tool discovery", () => { }), ); + it.effect("describes a tool's declared annotations, and omits the key when it has none", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + + const annotated = yield* describeTool(executor, "crm.org.main.createContact"); + expect(annotated.annotations).toEqual({ + requiresApproval: true, + approvalDescription: "Creates a contact record in the CRM", + mayElicit: false, + }); + + const plain = yield* describeTool(executor, "crm.org.main.listContacts"); + expect(plain.annotations).toBeUndefined(); + }), + ); + it.effect("serves an observed shape with a provenance note once a schemaless tool runs", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 2df47644ed..a0c59a2cb8 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -82,6 +82,13 @@ type DescribedTool = { readonly outputTypeScript?: string; readonly outputTypeScriptNote?: string; readonly typeScriptDefinitions?: Record; + /** The tool's declared annotations, when it carries any. Lets code inside + * `execute` branch on approval posture without parsing the description. */ + readonly annotations?: { + readonly requiresApproval?: boolean; + readonly approvalDescription?: string; + readonly mayElicit?: boolean; + }; /** Set when the path resolves to no tool — mirrors invoke's tool_not_found. */ readonly error?: { readonly code: "tool_not_found"; @@ -135,7 +142,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap = new Map< outputTypeScript: "DescribedTool", typeScriptDefinitions: { DescribedTool: - '{ path: string; name: string; description?: string; inputTypeScript?: string; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', + '{ path: string; name: string; description?: string; inputTypeScript?: string; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; annotations?: { requiresApproval?: boolean; approvalDescription?: string; mayElicit?: boolean; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', }, }, ], @@ -306,7 +313,10 @@ const extractNamespace = (path: string): string => { */ export const makeExecutorToolInvoker = ( executor: Executor, - options: { readonly invokeOptions: InvokeOptions }, + options: { + readonly invokeOptions: InvokeOptions; + readonly onConnectedToolCall?: (path: string) => void; + }, ): SandboxToolInvoker => ({ invoke: Effect.fn("mcp.tool.dispatch")(function* ({ path, args }) { yield* Effect.annotateCurrentSpan({ @@ -372,6 +382,12 @@ export const makeExecutorToolInvoker = ( // outcome annotation the dispatch span reads as healthy even when the // caller hit an upstream error or auth wall. yield* annotateToolResultOutcome(result); + const connectedToolPath = parseToolAddress(String(address)) + ? addressToPath(String(address)) + : undefined; + if (connectedToolPath && (!isToolResult(result) || result.ok)) { + options.onConnectedToolCall?.(connectedToolPath); + } if (isToolResult(result)) { return result; } @@ -656,7 +672,7 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul /** What `tools.search()` calls inside the sandbox. */ export const searchTools = Effect.fn("executor.tools.search")(function* ( - executor: Executor, + executor: { readonly tools: Pick }, query: string, limit = 12, options?: { readonly namespace?: string; readonly offset?: number }, @@ -883,6 +899,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( } : {}), typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), + ...(schema.annotations ? { annotations: schema.annotations } : {}), }; return described; }); diff --git a/packages/core/sdk/src/elicitation.ts b/packages/core/sdk/src/elicitation.ts index 290349e3cd..ede83332e3 100644 --- a/packages/core/sdk/src/elicitation.ts +++ b/packages/core/sdk/src/elicitation.ts @@ -17,6 +17,31 @@ import { ElicitationId, ToolAddress } from "./ids"; export const ElicitationMeta = Schema.Record(Schema.String, Schema.Unknown); export type ElicitationMeta = typeof ElicitationMeta.Type; +/** The persistence scopes an approval OFFERS, when its terms leave that to + * the answer. Codex Computer Use sends `persist: ["session", "always"]` and + * remembers the app only if the reply names one; a bare accept is a + * one-time approval and the very next call asks again. Chrome's per-site + * approval sends `persist: "always"` — a statement of what accepting + * means, not a choice — and contributes nothing here. */ +export const offeredPersistence = (meta: ElicitationMeta | undefined): readonly string[] => { + const persist = meta?.["persist"]; + return Array.isArray(persist) && persist.every((scope) => typeof scope === "string") + ? persist + : []; +}; + +/** What an accepted approval carries back, in the request's own vocabulary. + * + * Closed on purpose, the mirror of the request-side projection: an answer + * can only state terms this contract names, so no host can grant something + * the prompt never offered. `persist` is the one term that is a choice — + * one of `offeredPersistence(request.meta)`, or absent for a one-time + * approval. */ +export const ElicitationResponseMeta = Schema.Struct({ + persist: Schema.optional(Schema.String), +}); +export type ElicitationResponseMeta = typeof ElicitationResponseMeta.Type; + /** Tool needs structured input from the user (render a form). */ export const FormElicitation = Schema.TaggedStruct("FormElicitation", { message: Schema.String, @@ -45,14 +70,26 @@ export const ElicitationResponse = Schema.Struct({ action: ElicitationAction, /** Present when `action` is "accept" — the data the user provided. */ content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + /** The answer's own terms, meaningful only with "accept". */ + meta: Schema.optional(ElicitationResponseMeta), }); export type ElicitationResponse = typeof ElicitationResponse.Type; +/** Who raised an elicitation. `"policy"` is the executor's own approval gate + * (`enforceApproval`): a consent-only form whose terms are exactly "run this + * tool with these arguments". `"tool"` is anything the tool itself asked for + * mid-call, which may carry its own terms (a permanent site grant, a scope + * choice) even when the schema is empty. A host that has already obtained + * consent for the tool call may auto-accept the former and must never + * auto-accept the latter. Absent means unknown, which reads as `"tool"`. */ +export type ElicitationSource = "policy" | "tool"; + /** Handler input — the tool address being invoked, its args, and the request. */ export interface ElicitationContext { readonly address: ToolAddress; readonly args: unknown; readonly request: ElicitationRequest; + readonly source?: ElicitationSource; } /** Host-provided handler the SDK calls when a tool suspends for input. */ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 6bfe859466..b01c62dbc7 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -199,7 +199,7 @@ import { ORG_SUBJECT, type ExecutorOwnerPolicyContext, } from "./owner-policy"; -import { ToolSchemaView, type IntegrationDetectionResult } from "./types"; +import { ToolAnnotationsView, ToolSchemaView, type IntegrationDetectionResult } from "./types"; import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool"; import { buildToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; @@ -208,6 +208,7 @@ import { exchangeClientCredentials, isPermanentTokenRejection, isUnusableSuccessTokenResponse, + optionalScopesFromAuthorizationUrl, shouldRefreshToken, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy, @@ -1219,6 +1220,30 @@ const rowToTool = ( }; }; +// Projects a tool's annotations onto the schema view. Plugins persist extra +// keys alongside the declared contract (the mcp plugin stores its upstream tool +// name and `_meta` there so they survive to invokeTool), so the three declared +// fields are picked explicitly rather than spread: a caller reading the view +// gets the contract in `tool.ts` and nothing a plugin keeps for itself. +const toolAnnotationsView = ( + annotations: ToolAnnotations | undefined, +): ToolAnnotationsView | undefined => { + if (!annotations) return undefined; + const view: { + requiresApproval?: boolean; + approvalDescription?: string; + mayElicit?: boolean; + } = {}; + if (typeof annotations.requiresApproval === "boolean") { + view.requiresApproval = annotations.requiresApproval; + } + if (typeof annotations.approvalDescription === "string") { + view.approvalDescription = annotations.approvalDescription; + } + if (typeof annotations.mayElicit === "boolean") view.mayElicit = annotations.mayElicit; + return Object.keys(view).length > 0 ? ToolAnnotationsView.make(view) : undefined; +}; + // --------------------------------------------------------------------------- // Condition builders // --------------------------------------------------------------------------- @@ -4483,6 +4508,12 @@ export const createExecutor = Effect.succeed([] as readonly Tool[]), ), ); + if (input.toolSync === "background") { + const fiber = yield* Effect.forkDetach( + syncTools.pipe( + Effect.catch((error) => + Effect.logWarning("executor OAuth tool sync failed", { + integration: String(ref.integration), + connection: String(ref.name), + error: describeSyncFailure(error), + }), + ), + Effect.withSpan("executor.oauth.tools.sync", { + attributes: { + "executor.integration": String(ref.integration), + "executor.connection": String(ref.name), + }, + }), + ), + ); + config.waitUntil?.( + new Promise((resolve) => fiber.addObserver(() => resolve(undefined))), + ); + } else { + yield* syncTools; + } }), ); @@ -5692,6 +5754,7 @@ export const createExecutor = Effect.Effect; readonly complete: ( input: OAuthCompleteInput, + options?: OAuthCompleteOptions, ) => Effect.Effect< Connection, OAuthCompleteError | OAuthSessionNotFoundError | OrgWriteDeniedError | StorageFailure diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 319bf22db9..7496ce1efd 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Predicate } from "effect"; +import { Deferred, Effect, Fiber, Option, Predicate } from "effect"; import { withQueryContext } from "@executor-js/fumadb/query"; import { @@ -264,6 +264,98 @@ describe("oauth.start / oauth.complete", () => { ), ); + it.effect("complete returns after the durable grant while remote tool discovery continues", () => + Effect.scoped( + Effect.gen(function* () { + const discoveryStarted = yield* Deferred.make(); + const releaseDiscovery = yield* Deferred.make(); + const keptAlive: Promise[] = []; + const slowOAuthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.gen(function* () { + yield* Deferred.succeed(discoveryStarted, undefined); + yield* Deferred.await(releaseDiscovery); + return { + tools: [{ name: ToolName.make("whoami"), description: "whoami" }], + }; + }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: ["read"] }, + }, + ], + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Slow Acme", + config: {}, + }), + }), + }))(); + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const, + waitUntil: (promise) => keptAlive.push(promise), + }); + yield* Effect.addFinalizer(() => + Deferred.succeed(releaseDiscovery, undefined).pipe( + Effect.andThen(Effect.promise(() => Promise.all(keptAlive))), + ), + ); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main-account"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + + const completed = yield* executor.oauth + .complete({ state: started.state, code: callback.code }, { toolSync: "background" }) + .pipe(Effect.timeoutOption("1 second")); + expect( + Option.isSome(completed), + "the callback returns while listTools remains deliberately blocked", + ).toBe(true); + expect(keptAlive).toHaveLength(1); + yield* Deferred.await(discoveryStarted); + + const connections = yield* executor.connections.list({ integration: INTEG }); + expect(connections.map((connection) => String(connection.name))).toEqual(["mainAccount"]); + + yield* Deferred.succeed(releaseDiscovery, undefined); + yield* Effect.promise(() => Promise.all(keptAlive)); + const tools = yield* executor.tools.list({ integration: INTEG }); + expect(tools.map((tool) => String(tool.name))).toEqual(["whoami"]); + }), + ), + ); + it.effect("persists HTTP Basic client auth for code exchange and refresh", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 4dac673edc..d94e8c34ad 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -25,6 +25,7 @@ import { idTokenIdentityLabel, isPermanentTokenRejection, isUnusableSuccessTokenResponse, + optionalScopesFromAuthorizationUrl, refreshAccessToken, shouldRefreshToken, } from "./oauth-helpers"; @@ -193,16 +194,33 @@ describe("PKCE", () => { // buildAuthorizationUrl // --------------------------------------------------------------------------- -describe("providerAuthorizeExtras (Google offline/consent quirk)", () => { +describe("providerAuthorizeExtras (provider authorization quirks)", () => { it("adds access_type=offline + prompt=consent for the Google authorize host", () => { expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({ access_type: "offline", prompt: "consent", }); }); - it("adds nothing for non-Google hosts or an unparseable URL (token host ≠ authorize host)", () => { + + it("adds optional_scope for workspace-owned HubSpot OAuth clients", () => { + expect(providerAuthorizeExtras("https://app.hubspot.com/oauth/authorize")).toEqual({ + optional_scope: "content crm.objects.custom.read crm.schemas.custom.read", + }); + }); + + it("reads integration-declared optional_scope values from an authorization URL", () => { + expect( + optionalScopesFromAuthorizationUrl( + "https://app.hubspot.com/oauth/authorize?optional_scope=crm.objects.contacts.read+crm.objects.contacts.write+crm.objects.contacts.read", + ), + ).toEqual(["crm.objects.contacts.read", "crm.objects.contacts.write"]); + expect(optionalScopesFromAuthorizationUrl("not a url")).toEqual([]); + }); + + it("adds nothing for unrelated hosts, token hosts, or an unparseable URL", () => { expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({}); expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({}); + expect(providerAuthorizeExtras("https://api.hubapi.com/oauth/v3/token")).toEqual({}); expect(providerAuthorizeExtras("not a url")).toEqual({}); }); }); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index e40bdda068..c6e3209fe7 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -113,6 +113,16 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; /** Default token-endpoint timeout. */ export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; +/** HubSpot scopes that the registered app may grant but must receive through + * HubSpot's non-standard `optional_scope` authorize parameter. Keeping these + * out of the RFC `scope` parameter lets accounts without the corresponding + * product features complete consent while still granting them when present. */ +export const HUBSPOT_OPTIONAL_SCOPES = [ + "content", + "crm.objects.custom.read", + "crm.schemas.custom.read", +] as const; + /** RFC 8693 §2.1 token-exchange grant. */ export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; @@ -247,7 +257,12 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string * re-consent can silently keep the old scope set. Do not add * `include_granted_scopes=true` here: with historical grants on the same Google * consent app, Google folds those unrelated scopes into the new consent flow and - * can fail inside accounts.google.com before returning to our callback. */ + * can fail inside accounts.google.com before returning to our callback. + * + * HubSpot: app scopes marked optional are ignored when they are omitted from + * the provider-specific `optional_scope` parameter. The OpenAPI auth template + * can only declare RFC scopes, so this host-level quirk must apply to both + * first-party and workspace-owned HubSpot OAuth clients. */ export const providerAuthorizeExtras = ( authorizationUrl: string, ): Readonly> => { @@ -257,12 +272,30 @@ export const providerAuthorizeExtras = ( if (host === "accounts.google.com") { return { access_type: "offline", prompt: "consent" }; } + if (host === "app.hubspot.com") { + return { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") }; + } } catch { // Unparseable authorization URL — let buildAuthorizationUrl surface the error. } return {}; }; +/** Provider-specific scopes embedded in an integration's authorization + * endpoint. HubSpot models app-optional permissions with the non-standard + * `optional_scope` query parameter, so they are part of the integration's + * request contract rather than the registered OAuth app identity. */ +export const optionalScopesFromAuthorizationUrl = (authorizationUrl: string): readonly string[] => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input -> no optional scopes + try { + const value = new URL(authorizationUrl).searchParams.get("optional_scope"); + if (value == null) return []; + return [...new Set(value.split(/\s+/).filter(Boolean))]; + } catch { + return []; + } +}; + // --------------------------------------------------------------------------- // Regional token-endpoint rebind // diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index 6a5a281407..bc13050ded 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -373,6 +373,39 @@ describe("oauth.registerDynamicClient", () => { ), ); + it.effect("registers Vercel clients with offline_access for refresh tokens", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + scopes: ["openid", "offline_access"], + }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.registerDynamicClient({ + owner: "org", + slug: CLIENT, + issuer: "https://vercel.com", + registrationEndpoint: server.registrationEndpoint, + authorizationUrl: "https://vercel.com/oauth/authorize", + tokenUrl: server.tokenEndpoint, + resource: "https://mcp.vercel.com/", + scopes: ["openid"], + tokenEndpointAuthMethodsSupported: ["none"], + clientName: "Executor", + redirectUri: FLOW_REDIRECT_URI, + originIntegration: INTEG, + }); + + const requests = yield* server.requests; + const registration = requests.find( + (request) => request.path === "/register" && request.method === "POST", + ); + expect(registration?.body).toContain('"scope":"openid offline_access"'); + }), + ), + ); + it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 6e7aa41669..47b92d16e0 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -37,7 +37,10 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; * scopes (the MCP/no-template-scopes case). */ const makeScopePluginWithId = ( id: TId, - config: { readonly scopes: readonly string[] | null }, + config: { + readonly scopes: readonly string[] | null; + readonly authorizationUrl?: string; + }, options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {}, ) => definePlugin(() => ({ @@ -49,7 +52,10 @@ const makeScopePluginWithId = ( }), invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { - const cfg = record.config as { readonly scopes?: readonly string[] | null } | null; + const cfg = record.config as { + readonly scopes?: readonly string[] | null; + readonly authorizationUrl?: string; + } | null; const scopes = cfg?.scopes; if (scopes == null) { // No declared oauth scopes. Server-targeting methods (MCP) expose a @@ -73,7 +79,12 @@ const makeScopePluginWithId = ( label: "OAuth2", kind: "oauth", template: String(TEMPLATE), - oauth: { scopes }, + oauth: { + scopes, + ...(cfg?.authorizationUrl === undefined + ? {} + : { authorizationUrl: cfg.authorizationUrl }), + }, }, ]; }, @@ -82,13 +93,20 @@ const makeScopePluginWithId = ( ctx.core.integrations.register({ slug: INTEG, description: "Acme", - config: { scopes: config.scopes }, + config: { + scopes: config.scopes, + ...(config.authorizationUrl === undefined + ? {} + : { authorizationUrl: config.authorizationUrl }), + }, }), }), }))(); -const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) => - makeScopePluginWithId("acme", config); +const makeScopePlugin = (config: { + readonly scopes: readonly string[] | null; + readonly authorizationUrl?: string; +}) => makeScopePluginWithId("acme", config); const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) => makeScopePluginWithId("mcp", config, { discoversScopes: true }); @@ -159,11 +177,14 @@ const serveMetadataServer = (config: { * given server as its resource, returning the executor ready to `oauth.start`. * The shared setup for the discovery cases below; case (h) inlines its own (no * `resource`) because the absent resource IS the case under test. */ -const setupMcpScopeClient = (server: { - readonly authorizationEndpoint: string; - readonly tokenEndpoint: string; - readonly mcpResourceUrl: string; -}) => +const setupMcpScopeClient = ( + server: { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; + readonly mcpResourceUrl: string; + }, + options: { readonly authorizationEndpoint?: string } = {}, +) => Effect.gen(function* () { const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const; const { executor } = yield* makeTestWorkspaceHarness({ plugins }); @@ -171,7 +192,7 @@ const setupMcpScopeClient = (server: { yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, - authorizationUrl: server.authorizationEndpoint, + authorizationUrl: options.authorizationEndpoint ?? server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", @@ -223,6 +244,57 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect("moves integration-declared optional scopes out of scope into optional_scope", () => + Effect.scoped( + Effect.gen(function* () { + const declared = [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.companies.read", + ] as const; + const optional = [ + "crm.objects.contacts.read", + "crm.objects.companies.read", + "content", + ] as const; + const server = yield* serveOAuthTestServer({ scopes: [...declared] }); + const plugins = [ + memoryCredentialsPlugin(), + makeScopePlugin({ + scopes: declared, + authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`, + }), + ] as const; + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const url = new URL(started.authorizationUrl); + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["oauth"]); + expect(url.searchParams.get("optional_scope")?.split(/\s+/)).toEqual(optional); + }), + ), + ); + it.effect("filters stale declared scopes against authorization-server metadata", () => Effect.scoped( Effect.gen(function* () { @@ -405,6 +477,57 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect("requests Vercel offline_access so authorization-code connections can refresh", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMetadataServer({ + prm: { scopesSupported: ["openid"] }, + }); + const executor = yield* setupMcpScopeClient(server, { + authorizationEndpoint: "https://vercel.com/oauth/authorize", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([ + "openid", + "offline_access", + ]); + }), + ), + ); + + it.effect("does not add Vercel lifecycle scopes on a different port", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMetadataServer({ prm: { scopesSupported: ["openid"] } }); + const executor = yield* setupMcpScopeClient(server, { + authorizationEndpoint: "https://vercel.com:8443/oauth/authorize", + }); + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["openid"]); + }), + ), + ); + it.effect( "(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata", () => diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 5fcf64c26f..ba91260a51 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -60,6 +60,7 @@ import { type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, + type OAuthCompleteOptions, type OAuthGrant, type OAuthProbeInput, type OAuthProbeResult, @@ -149,6 +150,12 @@ export interface MintOAuthConnectionInput { * code was redeemed at a region other than the client's configured token * host (Datadog multi-site). Null means refresh uses the client's token URL. */ readonly oauthTokenUrl?: string | null; + /** Whether connection tool discovery must finish before the mint returns. + * Interactive authorization-code callbacks persist the fresh grant first, + * then synchronize the remote catalog in host-kept background work so a + * slow MCP server cannot strand the browser popup. Non-interactive grants + * keep the explicit behavior because their caller has no callback window. */ + readonly toolSync?: "explicit" | "background"; } /** Project an enterprise-managed mint failure onto the connect boundary, @@ -199,7 +206,14 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu * 8707 resource: a user may clear the client's resource (Entra v2 rejects * the parameter, #1789) without losing scope discovery. */ export type OAuthScopePolicy = - | { readonly kind: "scopes"; readonly scopes: readonly string[] } + | { + readonly kind: "scopes"; + readonly scopes: readonly string[]; + /** Provider-specific scopes declared on the integration's authorization + * endpoint (HubSpot `optional_scope`). These must not also be sent in + * the RFC `scope` parameter. */ + readonly optionalScopes?: readonly string[]; + } | { readonly kind: "discover"; readonly discoveryUrl: string }; /** Everything the OAuth service needs from the executor: fuma access for the @@ -556,6 +570,23 @@ interface LoadedOAuthClient { readonly tokenRequestFormat?: "form" | "json"; } +/** Provider lifecycle scopes that are required to keep an authorization-code + * connection renewable but are omitted from the protected resource's API + * scope list. Vercel's MCP resource advertises only `openid`, while its + * authorization server issues a refresh token only when `offline_access` is + * requested. Keep the exception bound to Vercel's exact official authorize + * endpoint so an unrelated OAuth server never receives a broader request. */ +const additionalAuthorizationLifecycleScopes = (client: { + readonly authorizationUrl: string; +}): readonly string[] => { + if (!URL.canParse(client.authorizationUrl)) return []; + const authorization = new URL(client.authorizationUrl); + return authorization.origin === "https://vercel.com" && + authorization.pathname === "/oauth/authorize" + ? ["offline_access"] + : []; +}; + /** Where an OAuth app's client secret is stored in the default writable * provider — derived solely from the app's (owner, slug) identity. */ const clientSecretItemId = (owner: Owner, slug: OAuthClientSlug): string => @@ -1416,6 +1447,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } const authMethod = pickDcrAuthMethod(input.tokenEndpointAuthMethodsSupported); + const registrationScopes = dedupeScopes([ + ...input.scopes, + ...additionalAuthorizationLifecycleScopes(input), + ]); const information = yield* registerDynamicClientDcr( { registrationEndpoint: input.registrationEndpoint, @@ -1426,7 +1461,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { response_types: ["code"], token_endpoint_auth_method: authMethod, application_type: isLoopbackHttpUrl(flowRedirectUri) ? "native" : "web", - scope: input.scopes.length > 0 ? input.scopes.join(" ") : undefined, + scope: registrationScopes.length > 0 ? registrationScopes.join(" ") : undefined, }, }, { httpClientLayer, endpointUrlPolicy: deps.endpointUrlPolicy }, @@ -1985,9 +2020,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { : scopePolicy.kind === "discover" ? requestedScopes : yield* filterAuthorizationCodeScopes(client, requestedScopes); + const providerExtras = providerAuthorizeExtras(client.authorizationUrl); + const workspaceOptionalScopes = firstPartyFlow + ? [] + : dedupeScopes([ + ...(providerExtras.optional_scope ?? "").split(/\s+/).filter(Boolean), + ...(scopePolicy.kind === "scopes" ? (scopePolicy.optionalScopes ?? []) : []), + ]); + const workspaceOptionalScopeSet = new Set(workspaceOptionalScopes); const completeAuthorizationScopes = dedupeScopes([ - ...authorizationRequestedScopes, + ...authorizationRequestedScopes.filter((scope) => !workspaceOptionalScopeSet.has(scope)), ...(firstParty?.additionalAuthorizationScopes ?? []), + ...additionalAuthorizationLifecycleScopes(client), + ]); + const completeRequestedScopes = dedupeScopes([ + ...completeAuthorizationScopes, + ...workspaceOptionalScopes, ]); // authorization_code: persist a session + build the authorize URL. @@ -2050,7 +2098,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { payload: { owner: input.owner, clientOwner: input.clientOwner, - requestedScopes: completeAuthorizationScopes, + requestedScopes: completeRequestedScopes, }, expires_at: expiresAt, created_at: now, @@ -2072,7 +2120,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // without these Google returns no refresh token and won't re-consent // to widen scopes on reconnect. extraParams: { - ...providerAuthorizeExtras(client.authorizationUrl), + ...providerExtras, + ...(workspaceOptionalScopes.length > 0 + ? { optional_scope: workspaceOptionalScopes.join(" ") } + : {}), ...(firstParty?.authorizationExtraParams ?? {}), }, endpointUrlPolicy: deps.endpointUrlPolicy, @@ -2092,6 +2143,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const complete = ( input: OAuthCompleteInput, + options?: OAuthCompleteOptions, ): Effect.Effect< Connection, OAuthCompleteError | OAuthSessionNotFoundError | OrgWriteDeniedError | StorageFailure @@ -2229,6 +2281,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // Persist the regional token endpoint ONLY when it differs from the // client's configured one, so refresh redeems against the same region. tokenUrl === client.tokenUrl ? null : tokenUrl, + // The grant and connection row are the callback's durable contract. + // Remote catalog discovery can be arbitrarily slow and must not keep + // the popup waiting after that contract has committed. + options?.toolSync ?? "explicit", ).pipe( Effect.mapError((cause) => Predicate.isTagged(cause, "OrgWriteDeniedError") @@ -2304,6 +2360,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { /** Regional token endpoint override to persist when the code was redeemed * off the client's configured host; null to use the client's token URL. */ oauthTokenUrl: string | null, + toolSync: "explicit" | "background" = "explicit", ): Effect.Effect => Effect.gen(function* () { // The token exchange may outlive the role that admitted `start`. Re-read @@ -2371,6 +2428,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { oauthScope, missingOAuthScopes: missingScopes, oauthTokenUrl, + toolSync, }); }); diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index 17b8fbc321..df32bcda0f 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -138,6 +138,7 @@ export type TestConfigOptions["orgWrites"]; + readonly waitUntil?: ExecutorConfig["waitUntil"]; }; export const makeTestConfig = ( @@ -181,6 +182,7 @@ export const makeTestConfig = ; - validateMcpSessionOwner: (identity: { - readonly accountId: string; - readonly organizationId: string; - }) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; + validateMcpSessionOwner: ( + identity: { + readonly accountId: string; + readonly organizationId: string; + }, + resource: McpResource, + ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; }; class StaleCloseTransport implements Transport { @@ -357,6 +360,23 @@ it("records a demoted browser approver's current role in a waiting decision", as await expect(waiting).resolves.toEqual({ response: approval, orgWriteAccess: "denied" }); }); +it("keeps the chosen approval lifetime when reading a stored decision", async () => { + const session = await makeHarnessSession(); + const executionId = "exec-stored-persistence"; + const response = { + action: "accept", + content: {}, + meta: { persist: "session" }, + } satisfies ResumeResponse; + await session.ctx.storage.put(`approval-response:${executionId}`, { + response, + orgWriteAccess: "allowed", + }); + const decision = await Effect.runPromise(session.waitForApprovalResponse(executionId)); + expect(decision).toEqual({ response, orgWriteAccess: "allowed" }); + expect(await session.ctx.storage.get(`approval-response:${executionId}`)).toBeUndefined(); +}); + // The negotiated MCP-Apps capability arrives once, at `initialize`, and lives // in the rebuilt server's memory. These pin the storage round-trip that lets a // cold-restored session rebuild with it instead of silently downgrading every @@ -642,10 +662,26 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); await expect( - session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }), + session.validateMcpSessionOwner( + { accountId: "user-1", organizationId: "org-1" }, + defaultMcpResource, + ), ).resolves.toBe("ok"); }); + it("rejects the same owner on a different MCP resource", async () => { + const session = await makeHarnessSession(); + const identity = { accountId: "user-1", organizationId: "org-1" }; + + await expect(session.validateMcpSessionOwner(identity, defaultMcpResource)).resolves.toBe("ok"); + await expect( + session.validateMcpSessionOwner(identity, { + kind: "toolkit", + slug: "other-toolkit", + }), + ).resolves.toBe("forbidden"); + }); + it("single-flights concurrent same-session restore after idle disposal", async () => { const session = await makeHarnessSession(); const firstRestoreEntered = makeDeferred(); @@ -666,14 +702,20 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); - const first = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }); - const second = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }); + const first = session.validateMcpSessionOwner( + { + accountId: "user-1", + organizationId: "org-1", + }, + defaultMcpResource, + ); + const second = session.validateMcpSessionOwner( + { + accountId: "user-1", + organizationId: "org-1", + }, + defaultMcpResource, + ); await firstRestoreEntered.promise; await Promise.resolve(); @@ -702,10 +744,13 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); - const restore = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }); + const restore = session.validateMcpSessionOwner( + { + accountId: "user-1", + organizationId: "org-1", + }, + defaultMcpResource, + ); const sdkStart = session.onStart(); await firstStartEntered.promise; @@ -813,7 +858,7 @@ describe("McpAgentSessionDOBase init survives a platform reset of its bookkeepin buildMcpServer: () => Effect.Effect<{ mcpServer: McpServer; engine: unknown }>; openSessionDb: () => { readonly end: () => void }; resolveSessionMeta: () => Effect.Effect; - validateMcpSessionOwner: (identity: McpApprovalOwner) => Promise; + validateMcpSessionOwner: (identity: McpApprovalOwner, resource: McpResource) => Promise; }; const sessionMeta: SessionMeta = { @@ -887,7 +932,10 @@ describe("McpAgentSessionDOBase init survives a platform reset of its bookkeepin expect(storage.alarm, "the write that failed left no alarm").toBeUndefined(); await expect( - session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }), + session.validateMcpSessionOwner( + { accountId: "user-1", organizationId: "org-1" }, + defaultMcpResource, + ), ).resolves.toBe("ok"); expect(storage.alarm, "the next request re-establishes the idle clock").toBeGreaterThan(0); }); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 578a64fe1d..5a90dc5978 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -19,11 +19,9 @@ import { type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; -import { - ResumeResponsePayload, - decodeResumeResponse, -} from "@executor-js/host-mcp/browser-approval"; +import { defaultMcpResource, mcpResourceKey, type McpResource } from "@executor-js/host-mcp"; +import { decodeResumeResponse, type McpToolMode } from "@executor-js/host-mcp/browser-approval"; +import { ElicitationResponse } from "@executor-js/sdk"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors"; @@ -77,6 +75,9 @@ interface McpSessionInitBase { * tools, read off `?search_tools=` at connect time. Absent means the * default (disabled). */ readonly searchToolsEnabled?: boolean; + /** The tool surface, read off `?mode=` at connect time. Absent means the + * default (codemode). */ + readonly toolMode?: McpToolMode; /** The MCP resource the session was minted against (`/mcp` default vs a * `/mcp/toolkits/` toolkit), so the tool catalog is scoped to it. */ readonly resource: McpResource; @@ -151,6 +152,11 @@ interface SessionMetaBase { * {@link McpSessionInit}). Absent — including for sessions persisted before * the flag existed — means the default (disabled). */ readonly searchToolsEnabled?: boolean; + /** The tool surface (carried from {@link McpSessionInit}). Absent — + * including for sessions persisted before the field existed — means + * codemode. A cold restore MUST rebuild the same surface the client first + * saw, or its cached tool names stop resolving mid-conversation. */ + readonly toolMode?: McpToolMode; /** The MCP resource the session serves (carried from {@link McpSessionInit}); * `buildMcpServer` scopes the tool catalog to it. */ readonly resource: McpResource; @@ -205,7 +211,7 @@ const MCP_MESSAGE_HEADER = "cf-mcp-message"; const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000; const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`; const BrowserApprovalDecisionStorage = Schema.Struct({ - response: ResumeResponsePayload, + response: ElicitationResponse, orgWriteAccess: Schema.Literals(["allowed", "denied"]), }); const decodeBrowserApprovalDecision = Schema.decodeUnknownOption(BrowserApprovalDecisionStorage); @@ -1591,6 +1597,7 @@ export abstract class McpAgentSessionDOBase< async validateMcpSessionOwner( identity: McpApprovalOwner, + resource: McpResource, ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { const self = this; return Effect.runPromise( @@ -1619,10 +1626,11 @@ export abstract class McpAgentSessionDOBase< Effect.withSpan("McpSessionDO.restore_transport_runtime"), ); } - return identity.accountId === sessionMeta.userId && - identity.organizationId === sessionMeta.organizationId - ? ("ok" as const) - : ("forbidden" as const); + const ownerMatches = + identity.accountId === sessionMeta.userId && + identity.organizationId === sessionMeta.organizationId; + const resourceMatches = mcpResourceKey(resource) === mcpResourceKey(sessionMeta.resource); + return ownerMatches && resourceMatches ? ("ok" as const) : ("forbidden" as const); }).pipe( Effect.withSpan("McpSessionDO.validateMcpSessionOwner"), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: DO RPC exposes Promise results diff --git a/packages/hosts/cloudflare/src/mcp/do-headers.ts b/packages/hosts/cloudflare/src/mcp/do-headers.ts index 02c4b196d0..6690331b1b 100644 --- a/packages/hosts/cloudflare/src/mcp/do-headers.ts +++ b/packages/hosts/cloudflare/src/mcp/do-headers.ts @@ -133,5 +133,7 @@ export { readArtifactsEnabled, readElicitationMode, readSearchToolsEnabled, + readToolMode, type McpElicitationMode, + type McpToolMode, } from "@executor-js/host-mcp/browser-approval"; diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 125e4e935d..696e16b304 100644 --- a/packages/hosts/cloudflare/src/mcp/session-stub.ts +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -1,4 +1,5 @@ import type { ResumeResponse } from "@executor-js/execution"; +import type { McpResource } from "@executor-js/host-mcp"; import type { IncomingTraceHeaders, @@ -18,6 +19,7 @@ export interface McpSessionNamespace { export interface McpSessionStub { readonly validateMcpSessionOwner: ( identity: McpApprovalOwner, + resource: McpResource, ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; readonly _cf_scheduleDestroy: () => Promise; readonly getPausedExecutionForApproval: ( diff --git a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx index a8d16a5ba2..cc1af323f9 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/inner-renderer.tsx @@ -94,10 +94,32 @@ Object.assign(globalThis, { SharedWorker: blockedNetwork("SharedWorker"), }); +// Grab these before any model-written code runs. The saved postMessage and the +// private nonce mean generated code can't fake or snoop on our open-link +// messages, even if it later overwrites `parent.postMessage`. +const postParentMessage = window.parent.postMessage.bind(window.parent); +const openLinkNonce = Array.from(crypto.getRandomValues(new Uint32Array(4))).join("-"); + const sendParent = (message: Record) => { - window.parent.postMessage({ ...message, token }, "*"); + postParentMessage({ ...message, token }, "*"); }; +// The sandbox can't open popups on its own (no `allow-popups`, on purpose), so +// a plain `` click does nothing. We hand those clicks to the +// host, which opens the link for us. Normal same-page links are left alone. +document.addEventListener("click", (event) => { + // Only real user clicks count. Generated code can call `element.click()`, but + // it can't forge a trusted event, so those are ignored here. + if (!event.isTrusted || event.defaultPrevented || event.button !== 0) return; + const target = event.target; + if (!(target instanceof Element)) return; + const anchor = target.closest("a[href]"); + if (!anchor || anchor.target.toLowerCase() !== "_blank" || anchor.href === "") return; + + event.preventDefault(); + sendParent({ type: "executor.openLink", url: anchor.href, openLinkNonce }); +}); + const requestParent = (message: ParentRequestPayload): Promise => { const requestId = ++nextRequestId; return new Promise((resolve, reject) => { @@ -429,4 +451,4 @@ const resizeObserver = new ResizeObserver(([entry]) => { }); resizeObserver.observe(document.body); -sendParent({ type: "executor.renderer.ready" }); +sendParent({ type: "executor.renderer.ready", openLinkNonce }); diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts index acf79110e7..a05f0f9262 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts @@ -69,6 +69,7 @@ type HostState = { readonly initialized: boolean; readonly toolCalls: HostToolCall[]; readonly resumeCalls: HostToolCall[]; + readonly openLinks: string[]; }; type BrowserHostWindow = Window & { @@ -394,6 +395,23 @@ function App() { } `; +const generatedExternalLinkCode = ` +function App() { + return ( + + + + Open pull request + + + Unsafe link + + + + ); +} +`; + /** A different artifact, for asserting what a SECOND delivery does and does not * do — its own marker so the test waits on the new render, not the old one. */ const generatedSecondRenderCode = ` @@ -764,6 +782,7 @@ const createHostHtml = (shellUrl: string) => ` initialized: false, toolCalls: [], resumeCalls: [], + openLinks: [], }; window.__mcpHostState = state; @@ -827,6 +846,12 @@ const createHostHtml = (shellUrl: string) => ` return; } + if (message.method === "ui/open-link" && message.id !== undefined) { + state.openLinks.push(message.params?.url); + respond(event.source, message.id, {}); + return; + } + if (message.method === "tools/call" && message.id !== undefined) { const params = message.params ?? {}; state.toolCalls.push(params); @@ -1582,6 +1607,70 @@ describe("MCP app generated UI browser isolation", () => { } }, 30_000); + it("routes pull request links through the host without granting popup access", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedExternalLinkCode); + await innerFrame.locator("#pr-link").waitFor({ timeout: 10_000 }); + + // Generated code knows the public renderer token, but must not be able + // to replace the private click authorization by repeating the handshake. + await innerFrame.evaluate(() => { + const token = document + .querySelector('meta[name="executor-render-token"]') + ?.getAttribute("content"); + if (!token) throw new Error("Renderer token missing"); + window.parent.postMessage( + { type: "executor.renderer.ready", token, openLinkNonce: "forged" }, + "*", + ); + window.parent.postMessage( + { + type: "executor.openLink", + token, + openLinkNonce: "forged", + url: "https://example.com/forged", + }, + "*", + ); + document.querySelector("#pr-link")?.click(); + }); + await page.waitForTimeout(100); + expect((await getHostState(page)).openLinks).toEqual([]); + + // Click the nested label, not the anchor itself: real links commonly wrap + // text and icons, and the renderer must recover the owning anchor. + await innerFrame.locator("#pr-link span").click(); + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.openLinks.length === 1, + ); + + const hostState = await getHostState(page); + expect(hostState.openLinks).toEqual(["https://example.com/pulls/123"]); + + // The frame can ask only for web URLs. Carrying `javascript:` into the + // host would move model-written code from the opaque sandbox origin into + // the console's origin, so it is neither opened nor executed. + await innerFrame.locator("#unsafe-link").click(); + await page.waitForTimeout(100); + expect((await getHostState(page)).openLinks).toEqual(["https://example.com/pulls/123"]); + expect(await innerFrame.locator("body").getAttribute("data-unsafe")).toBeNull(); + + expect( + await innerFrame.locator("#pr-link").count(), + "the generated frame was not navigated away", + ).toBe(1); + expect( + await shellFrame.locator('iframe[title="Generated UI"]').getAttribute("sandbox"), + "model-written code still has no direct popup permission", + ).toBe("allow-scripts"); + } finally { + await page.close(); + } + }, 30_000); + // An artifact that uses two accounts of one integration tags each call site // with a role. The role has to survive four hops — the inner proxy's `apply` // trap, the TanStack cache key, the postMessage bridge, and the diff --git a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts index 6d7744d0c6..b0983344d9 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts +++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts @@ -33,12 +33,16 @@ export type RequestTrustedInteraction = ( interaction: TrustedInteraction, ) => Promise; -const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; +const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/; + +const formatToolPathSegment = (segment: string): string => + TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`; /** * The ONE grammar the shell ever puts on the `execute-action` wire: * - * return await tools.("")?(.)*() + * return await tools("")?*() * * A single proxy-shaped tool call, nothing else — no statements, no loops, no * composition. The server parses `execute-action` against exactly this shape @@ -71,10 +75,12 @@ export function toolCallCode( if (role !== undefined && (typeof role !== "string" || role.length === 0)) { throw new Error("Invalid tool role."); } - const [head, ...rest] = parts; + const head = parts[0]; + if (head === undefined) throw new Error("Invalid tool path."); + const rest = parts.slice(1); const tag = role === undefined ? "" : `(${JSON.stringify(role)})`; - const trailer = rest.length > 0 ? `.${rest.join(".")}` : ""; - return `return await tools.${head}${tag}${trailer}(${JSON.stringify(args[0] ?? {})})`; + const target = `${formatToolPathSegment(head)}${tag}${rest.map(formatToolPathSegment).join("")}`; + return `return await tools${target}(${JSON.stringify(args[0] ?? {})})`; } /** diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx index c9cfbe52bc..363b6972ab 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -69,7 +69,8 @@ type RendererRequest = args: unknown; role?: unknown; } - | { type: "executor.renderer.ready"; token: string } + | { type: "executor.renderer.ready"; token: string; openLinkNonce: unknown } + | { type: "executor.openLink"; token: string; url: unknown; openLinkNonce: unknown } | { type: "executor.renderer.config"; token: string; config: unknown } | { type: "executor.renderer.size"; token: string; height: unknown } | { type: "executor.renderer.error"; token: string; message: unknown } @@ -254,6 +255,14 @@ const buildRendererSrcDoc = (token: string): string => { const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +/** The frame is untrusted, so only allow plain web links through. A + * `javascript:` URL would run as the host page, not inside the sandbox. */ +const isExternalWebUrl = (value: unknown): value is string => { + if (typeof value !== "string" || !URL.canParse(value)) return false; + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; +}; + // --------------------------------------------------------------------------- // Remembered approvals ("Approve and don't ask again") // --------------------------------------------------------------------------- @@ -378,6 +387,10 @@ export function McpAppsShell({ const pendingInteractionRef = useRef(null); const rendererFrameRef = useRef(null); const rendererRef = useRef(null); + // The nonce the renderer made before running any generated code. We never + // send it back into the frame, so a link request has to prove it knows this + // nonce, not just the public renderer token. + const openLinkAuthorizationRef = useRef<{ token: string; nonce: string } | null>(null); // Whether the embedding host can store a preview snapshot. Held in a ref // because the renderer message handler is built once and must not be rebuilt // when the context changes. @@ -533,6 +546,14 @@ export function McpAppsShell({ }; if (data.type === "executor.renderer.ready") { + // The bootstrap runs before generated code. A later ready message is + // untrusted and must not replace its private click authorization. + if (openLinkAuthorizationRef.current?.token === current.token) return; + if (typeof data.openLinkNonce !== "string" || data.openLinkNonce === "") return; + openLinkAuthorizationRef.current = { + token: current.token, + nonce: data.openLinkNonce, + }; postToRenderer({ type: "executor.render", code: current.code, @@ -550,6 +571,23 @@ export function McpAppsShell({ return; } + // The frame can't open links itself, so it relays the click to us and we + // ask the host to open it. We check the nonce and the URL first. + if (data.type === "executor.openLink") { + const authorization = openLinkAuthorizationRef.current; + if ( + authorization?.token !== current.token || + data.openLinkNonce !== authorization.nonce || + !isExternalWebUrl(data.url) + ) { + return; + } + app.openLink({ url: data.url }).catch((error: unknown) => { + console.error("[executor-shell] Failed to open generated link:", error); + }); + return; + } + if (data.type === "executor.renderer.config") { setRenderer((prev) => prev && prev.token === current.token @@ -633,7 +671,7 @@ export function McpAppsShell({ window.addEventListener("message", handleRendererMessage); return () => window.removeEventListener("message", handleRendererMessage); - }, [hostContext?.theme, postToRenderer]); + }, [app, hostContext?.theme, postToRenderer]); useEffect(() => { if (renderer) { @@ -674,6 +712,7 @@ export function McpAppsShell({ height: 240, }; rendererRef.current = nextRenderer; + openLinkAuthorizationRef.current = null; setRenderer(nextRenderer); setComponent(null); setError(null); @@ -682,6 +721,7 @@ export function McpAppsShell({ setError(`Compilation error: ${msg}`); setComponent(null); rendererRef.current = null; + openLinkAuthorizationRef.current = null; setRenderer(null); } }, []); @@ -745,6 +785,7 @@ export function McpAppsShell({ }; setComponent(() => DataView); rendererRef.current = null; + openLinkAuthorizationRef.current = null; setRenderer(null); setError(null); }; diff --git a/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts index ed71c22ced..433858fedf 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell/tool-call-grammar.pin.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { parseToolCallCode } from "@executor-js/host-mcp/tool-call-code"; +import { formatToolCallCode, parseToolCallCode } from "@executor-js/host-mcp/tool-call-code"; import { toolCallCode } from "./proxy"; @@ -30,6 +30,11 @@ describe("execute-action tool-call grammar", () => { path: ["search"], args: [{ query: "github issues", limit: 12 }], }, + { + label: "a hyphenated integration slug", + path: ["cloudflare-bindings", "d1_database_query"], + args: [{ database_id: "db", sql: "SELECT 1" }], + }, { label: "an argument with a $ in an identifier-ish key", path: ["mongo", "org", "main", "find"], @@ -82,6 +87,12 @@ describe("execute-action tool-call grammar", () => { }); } + it("formats a resolved hyphenated integration safely", () => { + expect(formatToolCallCode(["cloudflare-bindings", "org", "default", "query"], {})).toBe( + 'return await tools["cloudflare-bindings"].org.default.query({})', + ); + }); + it("refuses to emit a path that would not parse", () => { expect(() => toolCallCode([], [])).toThrow("Invalid tool path."); expect(() => toolCallCode(["github", "issues; drop"], [])).toThrow("Invalid tool path."); diff --git a/packages/hosts/mcp/src/artifact-bindings.test.ts b/packages/hosts/mcp/src/artifact-bindings.test.ts index bbbe7c630a..44e2fafd03 100644 --- a/packages/hosts/mcp/src/artifact-bindings.test.ts +++ b/packages/hosts/mcp/src/artifact-bindings.test.ts @@ -29,6 +29,21 @@ describe("extractArtifactRoles", () => { expect(roles).toEqual([{ role: "vercel", integration: "vercel" }]); }); + it("reads a hyphenated integration from a bracket reference", () => { + const roles = extractArtifactRoles( + `useQuery(tools["cloudflare-bindings"].d1_database_query.queryOptions({ sql: "SELECT 1" }));`, + ); + expect(roles).toEqual([{ role: "cloudflare-bindings", integration: "cloudflare-bindings" }]); + }); + + it("reads a hyphenated integration from single-quoted bracket references", () => { + expect( + extractArtifactRoles( + `useQuery(tools['cloudflare-bindings']('production').query.queryOptions({}));`, + ), + ).toEqual([{ role: "production", integration: "cloudflare-bindings" }]); + }); + it("collapses repeated references to one role", () => { const roles = extractArtifactRoles( `useQuery(tools.linear.issues.list.queryOptions({})); diff --git a/packages/hosts/mcp/src/artifact-bindings.ts b/packages/hosts/mcp/src/artifact-bindings.ts index 9be7a15c39..d513aca047 100644 --- a/packages/hosts/mcp/src/artifact-bindings.ts +++ b/packages/hosts/mcp/src/artifact-bindings.ts @@ -92,14 +92,17 @@ const withCommentsBlanked = (code: string): string => code.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (text) => text.replace(/[^\n]/g, " ")); /** - * A `tools.` reference, with the optional role call that follows it. + * A tools root reference, with the optional role call that follows it. * * The role is captured from either quote flavour. Anything else after the root * — property access, a call with an object — is left to the caller's own path * handling; extraction only cares which integration slot is being reached. */ -const TOOLS_REFERENCE = - /(? { const scannable = withCommentsBlanked(code); const found = new Map(); for (const match of scannable.matchAll(TOOLS_REFERENCE)) { - const integration = match[1]; + const integration = match[1] ?? match[2] ?? match[3]; if (integration === undefined || RESERVED_TOOL_ROOTS.has(integration)) continue; - const role = match[2] ?? match[3] ?? integration; + const role = match[4] ?? match[5] ?? integration; if (role.length === 0) continue; if (!found.has(role)) found.set(role, { role, integration }); } diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts index bc0a958b20..a761f12bb8 100644 --- a/packages/hosts/mcp/src/artifacts-tools.test.ts +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -182,6 +182,13 @@ const structuredOf = (result: Awaited>): Record>): string => (result.content as Array<{ type: string; text: string }>)[0].text; +/** Assert source is available through both MCP result channels. */ +const expectArtifactSource = (result: Awaited>, code: string) => { + expect(structuredOf(result).code).toBe(code); + expect(textOf(result)).toContain("Source:"); + expect(textOf(result)).toContain(code); +}; + const toolNames = async (client: Client): Promise => (await client.listTools()).tools.map((tool) => tool.name); @@ -688,8 +695,11 @@ describe("MCP host — create-artifact", () => { url: "https://executor.test/artifacts/art_1", artifactId: "art_1", }); - // The model needs to be told to hand the URL over. + // The model needs to be told to hand the URL over. Source is a + // show-artifact read, not part of the create confirmation. expect(textOf(result)).toContain("https://executor.test/artifacts/art_1"); + expect(textOf(result)).not.toContain("Source:"); + expect(structuredOf(result)).not.toHaveProperty("code"); // Persistence is what makes the fallback possible at all. expect(store.calls).toHaveLength(1); expect(store.rows.get("art_1")?.code).toBe(COUNTER_CODE); @@ -1285,6 +1295,10 @@ describe("MCP host — artifact retrieval", () => { code: COUNTER_CODE, artifactId: "art_1", }); + // Apps-capable hosts still need the source on the text channel: a + // later restore or a client that starts advertising apps must not + // make `show-artifact` unusable for `edit-artifact`. + expectArtifactSource(shown, COUNTER_CODE); }, { artifacts: store.port }, ); @@ -1368,7 +1382,12 @@ describe("MCP host — artifact retrieval", () => { status: "fallback_url", url: "https://executor.test/artifacts/art_1", artifactId: "art_1", + code: COUNTER_CODE, }); + // The URL instruction stays; the source rides after it so a text-only + // host can copy `oldText` for `edit-artifact` from this result. + expect(textOf(shown)).toContain("https://executor.test/artifacts/art_1"); + expectArtifactSource(shown, COUNTER_CODE); }, { artifacts: store.port, @@ -1377,6 +1396,35 @@ describe("MCP host — artifact retrieval", () => { ); }); + it("returns show-artifact source when the client has no apps support and no web UI", async () => { + const store = makeArtifactStore(); + await Effect.runPromise( + store.port.save({ + title: "Saved earlier", + description: null, + code: COUNTER_CODE, + }), + ); + await withClient( + makeStubEngine({}), + NO_APPS_CAPS, + async (client) => { + const shown = await client.callTool({ + name: "show-artifact", + arguments: { id: "art_1" }, + }); + expect(structuredOf(shown)).toEqual({ + status: "fallback_unavailable", + reason: "mcp_apps_unsupported", + artifactId: "art_1", + code: COUNTER_CODE, + }); + expectArtifactSource(shown, COUNTER_CODE); + }, + { artifacts: store.port }, + ); + }); + it("reports a miss as an error result rather than failing the tool call", async () => { const store = makeArtifactStore(); await withClient( diff --git a/packages/hosts/mcp/src/browser-approval.ts b/packages/hosts/mcp/src/browser-approval.ts index a524b89fbb..c1412f6295 100644 --- a/packages/hosts/mcp/src/browser-approval.ts +++ b/packages/hosts/mcp/src/browser-approval.ts @@ -79,6 +79,21 @@ export const readSearchToolsEnabled = (request: Request): boolean => { return TRUE_QUERY_VALUES.has(value.toLowerCase()); }; +export type McpToolMode = "codemode" | "passthrough"; + +/** + * Read the tool surface mode off an MCP request's `?mode=` query. The default, + * `codemode`, serves `execute` (the model writes sandboxed TypeScript against + * `tools.*`). `?mode=passthrough` instead serves search and invoke, with no + * `execute`, `skills`, or `resume`. Invoke is marked destructive for native + * client approval. Any other value + * reads as the default. + */ +export const readToolMode = (request: Request): McpToolMode => { + const value = new URL(request.url).searchParams.get("mode"); + return value === "passthrough" ? "passthrough" : "codemode"; +}; + /** * Build the console approval URL for a paused execution: * `//resume/?mcp_session_id=` @@ -110,13 +125,22 @@ export const approvalUrlForRequest = ( export const ResumeResponsePayload = Schema.Struct({ action: Schema.Literals(["accept", "decline", "cancel"]), content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + persist: Schema.optional(Schema.String), }); const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload); /** Decode an untrusted resume payload, or `null` if it doesn't match the contract. */ -export const decodeResumeResponse = (raw: unknown): ResumeResponse | null => - Option.getOrNull(decodeResumeResponsePayload(raw)); +export const decodeResumeResponse = (raw: unknown): ResumeResponse | null => { + const decoded = decodeResumeResponsePayload(raw); + if (Option.isNone(decoded)) return null; + const { action, content, persist } = decoded.value; + return { + action, + ...(content === undefined ? {} : { content }), + ...(action === "accept" && persist !== undefined ? { meta: { persist } } : {}), + }; +}; const ACKNOWLEDGEMENT_TEXT = { accept: "I've approved it", diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index 9bfa914488..bdc51db85d 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -691,4 +691,89 @@ describe("pre-initialize dispatch through the in-memory session store", () => { expect(response.status).toBe(406); await sessions.close(); }); + + it("shuts down the scoped executor and custom closer when an idle session is evicted", async () => { + let executorClosed = 0; + let customClosed = 0; + const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig())); + const testExecutor = { + ...realExecutor, + close: () => + realExecutor.close().pipe( + Effect.tap(() => + Effect.sync(() => { + executorClosed += 1; + }), + ), + ), + }; + const engine = makeIdleTestEngine(); + const sessions = makeInMemoryMcpSessionStore( + () => + createExecutorMcpServer({ engine }).pipe( + Effect.map((mcpServer) => ({ + mcpServer, + engine, + executor: testExecutor, + close: () => { + customClosed += 1; + return Promise.resolve(); + }, + })), + ), + { sessionIdleTtlMs: IDLE_TTL_MS }, + ); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the store + try { + await openSession(sessions); + expect(sessions.sessionCount()).toBe(1); + expect(executorClosed).toBe(0); + expect(customClosed).toBe(0); + + // Advance clock past idle window and sweep. + expect(await sessions.sweepIdleSessions(Date.now() + IDLE_TTL_MS + 1)).toBe(1); + expect(sessions.sessionCount()).toBe(0); + expect(executorClosed).toBe(1); + expect(customClosed).toBe(1); + } finally { + await sessions.close(); + } + }); + + it("shuts down the scoped executor when sessions.close() is called", async () => { + let executorClosed = 0; + const realExecutor = await Effect.runPromise(createExecutor(makeTestConfig())); + const testExecutor = { + ...realExecutor, + close: () => + realExecutor.close().pipe( + Effect.tap(() => + Effect.sync(() => { + executorClosed += 1; + }), + ), + ), + }; + const engine = makeIdleTestEngine(); + const sessions = makeInMemoryMcpSessionStore( + () => + createExecutorMcpServer({ engine }).pipe( + Effect.map((mcpServer) => ({ + mcpServer, + engine, + executor: testExecutor, + })), + ), + { sessionIdleTtlMs: IDLE_TTL_MS }, + ); + + await openSession(sessions); + expect(sessions.sessionCount()).toBe(1); + expect(executorClosed).toBe(0); + + await sessions.close(); + expect(executorClosed).toBe(1); + expect(sessions.sessionCount()).toBe(0); + }); }); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index af9e149741..952f9bd83f 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -3,7 +3,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; -import type { OrgWriteAccess } from "@executor-js/sdk"; +import type { Executor, OrgWriteAccess } from "@executor-js/sdk"; import { buildResumeApprovalUrl, @@ -12,6 +12,8 @@ import { readArtifactsEnabled, readElicitationMode, readSearchToolsEnabled, + readToolMode, + type McpToolMode, } from "./browser-approval"; import { makeInProcessBrowserApprovalStore, @@ -31,7 +33,7 @@ import { type Principal, type McpResource, } from "./seams"; -import type { BrowserApprovalStore } from "./tool-server"; +import type { BrowserApprovalStore, McpPassthroughUnavailableError } from "./tool-server"; // --------------------------------------------------------------------------- // In-process McpSessionStore — the single-node serving store, shared by every @@ -98,6 +100,8 @@ export class McpEngineBuildError extends Data.TaggedError("McpEngineBuildError") export interface BuiltMcpServer { readonly mcpServer: McpServer; readonly engine: ExecutionEngine; + readonly executor?: Executor; + readonly close?: () => Promise; } /** The browser-mode wiring the store hands a build call when a session opts in. */ @@ -115,13 +119,15 @@ export interface McpBuildServerOptions { /** Whether this session serves the per-integration `search_` * tools. False unless the client connected with `?search_tools=true`. */ readonly searchToolsEnabled?: boolean; + /** The tool surface (`?mode=`): codemode (default) or passthrough. */ + readonly mode?: McpToolMode; } /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ export type McpBuildServer = ( principal: Principal, options?: McpBuildServerOptions, -) => Effect.Effect; +) => Effect.Effect; export interface InMemoryMcpSessionStore { /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */ @@ -243,6 +249,8 @@ export const makeInMemoryMcpSessionStore = ( const servers = new Map(); const owners = new Map(); const engines = new Map>(); + const executors = new Map(); + const closers = new Map Promise>(); const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); // Monotonic-ish last-touch stamp per live session, the first input the idle // sweep reads. Written on create and on every forwarded request. @@ -294,20 +302,33 @@ export const makeInMemoryMcpSessionStore = ( ): Promise => ignoreClose(id, "engine", engine ? () => Effect.runPromise(engine.shutdown) : undefined); + /** + * Shut down a session's scoped executor and its plugin resources (such as + * tool subprocesses and connection pools). Every disposal path goes through here. + */ + const shutdownExecutor = (id: string | null, executor: Executor | undefined): Promise => + ignoreClose(id, "executor", executor ? () => Effect.runPromise(executor.close()) : undefined); + const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { const transport = transports.get(id); const server = servers.get(id); const engine = engines.get(id); + const executor = executors.get(id); + const closer = closers.get(id); transports.delete(id); servers.delete(id); owners.delete(id); engines.delete(id); + executors.delete(id); + closers.delete(id); lastSeen.delete(id); activeRequests.delete(id); if (opts.transport) await ignoreClose(id, "transport", transport ? () => transport.close() : undefined); if (opts.server) await ignoreClose(id, "server", server ? () => server.close() : undefined); await shutdownEngine(id, engine); + await shutdownExecutor(id, executor); + await ignoreClose(id, "session", closer); }; /** @@ -397,13 +418,18 @@ export const makeInMemoryMcpSessionStore = ( ): McpBuildServerOptions => { const artifactsEnabled = readArtifactsEnabled(request); const searchToolsEnabled = readSearchToolsEnabled(request); + const toolMode = readToolMode(request); + const surface = { + artifactsEnabled, + searchToolsEnabled, + mode: toolMode, + }; const mode = readElicitationMode(request); if (mode !== "browser") { - return { artifactsEnabled, searchToolsEnabled, elicitationMode: { mode } }; + return { ...surface, elicitationMode: { mode } }; } return { - artifactsEnabled, - searchToolsEnabled, + ...surface, elicitationMode: { mode: "browser", // Prefer the pinned public origin; fall back to the request URL (correct @@ -430,7 +456,7 @@ export const makeInMemoryMcpSessionStore = ( ...buildOptionsFor(request, () => createdSessionId), resource, }).pipe( - Effect.flatMap(({ mcpServer, engine }) => + Effect.flatMap(({ mcpServer, engine, executor, close }) => Effect.gen(function* () { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), @@ -441,6 +467,8 @@ export const makeInMemoryMcpSessionStore = ( servers.set(sid, mcpServer); owners.set(sid, { principal, resource }); engines.set(sid, engine); + if (executor) executors.set(sid, executor); + if (close) closers.set(sid, close); lastSeen.set(sid, Date.now()); }, onsessionclosed: (sid) => void dispose(sid, { server: true }), @@ -458,19 +486,24 @@ export const makeInMemoryMcpSessionStore = ( orgWriteAccessForPrincipal(principal), () => { // Nothing was ever registered under a session id, so `dispose` has - // no entry to work from — release the three handles by hand, engine - // included. + // no entry to work from — release the handles by hand, engine + // and executor included. void ignoreClose(null, "transport", () => transport.close()); void ignoreClose(null, "server", () => mcpServer.close()); void shutdownEngine(null, engine); + void shutdownExecutor(null, executor); + if (close) void ignoreClose(null, "session", close); }, ); }), ), // A build failure has nowhere typed to go in the envelope; render a 500. - Effect.catchTag("McpEngineBuildError", () => - Effect.succeed(jsonRpcError(500, -32603, "Internal server error")), - ), + Effect.catchTags({ + McpEngineBuildError: () => + Effect.succeed(jsonRpcError(500, -32603, "Internal server error")), + McpPassthroughUnavailableError: () => + Effect.succeed(jsonRpcError(500, -32603, "Internal server error")), + }), ); }; @@ -623,7 +656,13 @@ export const makeInMemoryMcpSessionStore = ( sweepIdleSessions, close: async () => { if (sweepTimer !== undefined) clearInterval(sweepTimer); - const ids = new Set([...transports.keys(), ...servers.keys(), ...engines.keys()]); + const ids = new Set([ + ...transports.keys(), + ...servers.keys(), + ...engines.keys(), + ...executors.keys(), + ...closers.keys(), + ]); await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); }, }; diff --git a/packages/hosts/mcp/src/passthrough-tools.test.ts b/packages/hosts/mcp/src/passthrough-tools.test.ts new file mode 100644 index 0000000000..9d09425b72 --- /dev/null +++ b/packages/hosts/mcp/src/passthrough-tools.test.ts @@ -0,0 +1,898 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type * as Cause from "effect/Cause"; + +import type { ExecutionEngine } from "@executor-js/execution"; +import { + ToolAddress, + IntegrationSlug, + ConnectionName, + ToolName, + type Tool, + type ToolSchemaView, +} from "@executor-js/sdk"; + +import { readToolMode } from "./browser-approval"; +import { passthroughCallCode } from "./passthrough-tools"; +import { + createExecutorMcpServer, + McpPassthroughUnavailableError, + type ExecutorMcpServerConfig, + type McpToolsPort, +} from "./tool-server"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const projection = (input: { + readonly integration: string; + readonly name: string; + readonly owner?: "org" | "user"; + readonly connection?: string; + readonly description?: string; + readonly inputSchema?: unknown; + readonly requiresApproval?: boolean; + readonly static?: boolean; +}): Tool => { + const owner = input.owner ?? "org"; + const connection = input.connection ?? "main"; + return { + address: ToolAddress.make(`tools.${input.integration}.${owner}.${connection}.${input.name}`), + integration: IntegrationSlug.make(input.integration), + owner, + connection: ConnectionName.make(connection), + name: ToolName.make(input.name), + pluginId: "test", + description: input.description ?? `${input.integration} ${input.name}`, + ...(input.inputSchema === undefined ? {} : { inputSchema: input.inputSchema }), + ...(input.requiresApproval === undefined + ? {} + : { annotations: { requiresApproval: input.requiresApproval } }), + ...(input.static === undefined ? {} : { static: input.static }), + }; +}; + +/** Exercise the existing list/schema seam and record which schemas were requested. */ +const toolPort = ( + catalog: readonly Tool[], + schemaReads: string[] = [], + lists: string[] = [], +): McpToolsPort => ({ + list: (filter) => + Effect.sync(() => { + lists.push("list"); + return catalog.filter( + (tool) => + (filter?.integration === undefined || tool.integration === filter.integration) && + (filter?.owner === undefined || tool.owner === filter.owner) && + (filter?.connection === undefined || tool.connection === filter.connection), + ); + }), + schema: (address) => + Effect.sync(() => { + schemaReads.push(String(address)); + const tool = catalog.find((item) => item.address === address); + if (!tool) return null; + return { + address, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + } satisfies ToolSchemaView; + }), +}); + +/** A stub engine that records every executed code string and answers with a + * fixed value, so a test can prove a passthrough call became the expected + * single-call code and took `execute` (never `executeWithPause`). */ +const makeRecordingEngine = (result: unknown = { ok: true, data: { hello: "world" } }) => { + const executed: string[] = []; + let pausedCalls = 0; + const engine: ExecutionEngine = { + execute: (code) => + Effect.sync(() => { + executed.push(code); + return { result }; + }), + executeWithPause: () => + Effect.sync(() => { + pausedCalls += 1; + return { status: "completed" as const, result: { result } }; + }), + resume: () => Effect.succeed(null), + isExecutionSettled: undefined, + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), + shutdown: Effect.void, + }; + return { engine, executed, pausedCalls: () => pausedCalls }; +}; + +const withClient = async ( + config: ExecutorMcpServerConfig, + fn: (client: Client) => Promise, +) => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + connections: { list: () => Effect.succeed([]) }, + integrations: { list: () => Effect.succeed([]) }, + ...config, + }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions + try { + await fn(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; + +const decodeJsonString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.String)); +const decodeJsonRecord = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const decodeSearchItems = Schema.decodeUnknownSync( + Schema.Struct({ items: Schema.Array(Schema.Struct({ id: Schema.String })) }), +); + +const CATALOG: readonly Tool[] = [ + projection({ + integration: "github", + name: "issues.create", + requiresApproval: true, + inputSchema: { + type: "object", + properties: { title: { type: "string" }, body: { $ref: "#/$defs/Body" } }, + required: ["title"], + $defs: { Body: { type: "string" } }, + }, + }), + projection({ integration: "github", name: "issues.list" }), + projection({ integration: "linear", name: "issueCreate", requiresApproval: true }), +]; + +describe("passthrough catalog", () => { + it("emits exactly one awaited tool call with the whole address as one string literal", () => { + expect(passthroughCallCode("tools.github.org.main.issues.create", { title: "hi" })).toBe( + 'return await tools["github.org.main.issues.create"]({"title":"hi"});', + ); + expect(passthroughCallCode("linear.org.main.issueCreate", undefined)).toBe( + 'return await tools["linear.org.main.issueCreate"]({});', + ); + // `then` is reserved by every sandbox proxy; as part of one key it is + // just text, so such a tool stays callable. + expect(passthroughCallCode("tools.svc.org.main.items.then", {})).toBe( + 'return await tools["svc.org.main.items.then"]({});', + ); + }); + + it("keeps a hostile tool segment as data, never as code", () => { + // An OpenAPI spec controls its tool paths (`x-executor-toolPath`), so a + // segment can contain anything. It must land inside a JSON string. + const hostile = 'x"](await tools.victim.org.main.destroy({}))["'; + const code = passthroughCallCode(`tools.evil.org.main.${hostile}`, {}); + // Structural proof the payload never escapes the string literal: the + // source is exactly `return await tools[]();`, and + // that one string decodes back to the raw address. + const shape = /^return await tools\[("(?:[^"\\]|\\.)*")\]\((\{.*\})\);$/s.exec(code); + expect(shape).not.toBeNull(); + expect(decodeJsonString(shape![1]!)).toBe(`evil.org.main.${hostile}`); + // And the call's argument is the JSON we passed, untouched by the address. + expect(decodeJsonRecord(shape![2]!)).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// Wire flags +// --------------------------------------------------------------------------- + +describe("readToolMode", () => { + const request = (query: string) => new Request(`https://example.test/mcp${query}`); + + it("defaults to codemode and only accepts the exact passthrough spelling", () => { + expect(readToolMode(request(""))).toBe("codemode"); + expect(readToolMode(request("?mode=passthrough"))).toBe("passthrough"); + expect(readToolMode(request("?mode=Passthrough"))).toBe("codemode"); + expect(readToolMode(request("?mode=direct"))).toBe("codemode"); + }); +}); + +// --------------------------------------------------------------------------- +// Server: the served surface +// --------------------------------------------------------------------------- + +describe("passthrough mode server", () => { + it("lists live account metadata with paging, exact filters, and no schemas or secrets", async () => { + const { engine, executed } = makeRecordingEngine(); + const schemaReads: string[] = []; + const lists: string[] = []; + const reads: string[] = []; + const account = (integration: string, owner: "org" | "user", name: string) => ({ + integration, + owner, + name, + identityLabel: "Example account", + description: "Use for test issues", + lastHealth: { + status: "healthy" as const, + checkedAt: 123, + detail: "private probe details", + responseSample: [{ path: "token", value: "secret" }], + }, + oauthScope: "private grants", + provider: "private credential provider", + }); + let accounts = [ + account("github", "user", "main"), + account("github", "org", "main"), + account("github_other", "org", "main"), + account("unavailable", "org", "hidden"), + ]; + await withClient( + { + engine, + mode: "passthrough", + tools: toolPort(CATALOG, schemaReads, lists), + connections: { + list: () => + Effect.sync(() => { + reads.push("connections"); + return accounts; + }), + }, + integrations: { + list: () => + Effect.sync(() => { + reads.push("integrations"); + return [ + { + slug: IntegrationSlug.make("github"), + name: "GitHub", + description: "Issues and repositories", + }, + { + slug: IntegrationSlug.make("github_other"), + name: "Other GitHub", + description: "Another integration", + }, + { + slug: IntegrationSlug.make("not_connected"), + name: "Not connected", + description: "No account", + }, + ]; + }), + }, + }, + async (client) => { + await client.listTools(); + expect(reads).toEqual([]); + const first = await client.callTool({ + name: "integrations", + arguments: { integration: "github", limit: 1 }, + }); + expect(first.structuredContent).toEqual({ + items: [ + { + integration: "github", + integrationName: "GitHub", + integrationDescription: "Issues and repositories", + owner: "org", + connection: "main", + identityLabel: "Example account", + description: "Use for test issues", + lastHealth: { status: "healthy", checkedAt: 123 }, + }, + ], + total: 2, + hasMore: true, + nextOffset: 1, + }); + const next = await client.callTool({ + name: "integrations", + arguments: { integration: "github", limit: 1, offset: 1 }, + }); + expect(next.structuredContent).toMatchObject({ + items: [{ owner: "user" }], + total: 2, + hasMore: false, + nextOffset: null, + }); + const filtered = await client.callTool({ + name: "integrations", + arguments: { owner: "org", integration: "github" }, + }); + expect(filtered.structuredContent).toMatchObject({ + items: [{ owner: "org", integration: "github" }], + total: 1, + }); + const all = await client.callTool({ name: "integrations", arguments: {} }); + expect(all.structuredContent).toMatchObject({ total: 3 }); + expect(JSON.stringify(all)).not.toContain("secret"); + expect(JSON.stringify(all)).not.toContain("private"); + accounts = []; + const empty = await client.callTool({ name: "integrations", arguments: {} }); + expect(empty.structuredContent).toEqual({ + items: [], + total: 0, + hasMore: false, + nextOffset: null, + }); + expect(lists).toEqual([]); + expect(schemaReads).toEqual([]); + expect(executed).toEqual([]); + }, + ); + }); + + it("serves only the search/invoke guide as text", async () => { + const { engine } = makeRecordingEngine(); + await withClient({ engine, mode: "passthrough", tools: toolPort(CATALOG) }, async (client) => { + const index = await client.callTool({ name: "skills", arguments: {} }); + expect(JSON.stringify(index.content)).toContain("search-invoke"); + expect(JSON.stringify(index.content)).not.toContain("create-artifact"); + const guide = await client.callTool({ name: "skills", arguments: { name: "search-invoke" } }); + expect(guide.structuredContent).toBeUndefined(); + expect(JSON.stringify(guide.content)).toContain("integrations({})"); + expect(JSON.stringify(guide.content)).toContain("nextOffset"); + expect(JSON.stringify(guide.content)).not.toContain("tools.describe"); + for (const name of ["execute", "create-artifact", "artifact-style", "/tmp/SKILL.md"]) { + expect((await client.callTool({ name: "skills", arguments: { name } })).isError).toBe(true); + } + }); + }); + + it("filters exact accounts before ranking, pagination, and schema reads", async () => { + const { engine } = makeRecordingEngine(); + const schemaReads: string[] = []; + const catalog = [ + projection({ integration: "github", owner: "org", connection: "main", name: "issues.first" }), + projection({ + integration: "github", + owner: "org", + connection: "main", + name: "issues.second", + }), + projection({ + integration: "github", + owner: "user", + connection: "main", + name: "issues.first", + }), + projection({ + integration: "github", + owner: "org", + connection: "main2", + name: "issues.first", + }), + projection({ + integration: "github_other", + owner: "org", + connection: "main", + name: "issues.first", + }), + projection({ + integration: "github", + owner: "org", + connection: "main", + name: "issues.static", + static: true, + }), + ]; + await withClient( + { engine, mode: "passthrough", tools: toolPort(catalog, schemaReads) }, + async (client) => { + const args = { + query: "issues", + integration: "github", + owner: "org", + connection: "main", + limit: 1, + }; + const first = await client.callTool({ name: "search", arguments: args }); + expect(first.structuredContent).toMatchObject({ total: 2, hasMore: true, nextOffset: 1 }); + const next = await client.callTool({ name: "search", arguments: { ...args, offset: 1 } }); + expect(next.structuredContent).toMatchObject({ + total: 2, + hasMore: false, + nextOffset: null, + }); + expect(schemaReads.sort()).toEqual([ + "tools.github.org.main.issues.first", + "tools.github.org.main.issues.second", + ]); + const missing = await client.callTool({ + name: "search", + arguments: { ...args, connection: "absent" }, + }); + expect(missing.structuredContent).toEqual({ + items: [], + total: 0, + hasMore: false, + nextOffset: null, + }); + expect(schemaReads).toHaveLength(2); + }, + ); + }); + + it("serves four discovery and call tools even for 10000 tools", async () => { + const { engine, executed } = makeRecordingEngine(); + const schemaReads: string[] = []; + const lists: string[] = []; + const catalog = Array.from({ length: 10000 }, (_, i) => + projection({ + integration: "bench", + name: `record${i}`, + description: i === 9999 ? "cobalt orchard sentinel" : `benchmark record ${i}`, + }), + ); + await withClient( + { + engine, + mode: "passthrough", + searchToolsEnabled: true, + tools: toolPort(catalog, schemaReads, lists), + }, + async (client) => { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name).sort()).toEqual([ + "integrations", + "invoke", + "search", + "skills", + ]); + expect(JSON.stringify(listed).length).toBeLessThan(4000); + expect(lists).toEqual([]); + expect(schemaReads).toEqual([]); + const result = await client.callTool({ + name: "search", + arguments: { query: "cobalt orchard sentinel" }, + }); + expect(result.structuredContent).toMatchObject({ + total: 1, + hasMore: false, + nextOffset: null, + items: [{ id: "tools.bench.org.main.record9999" }], + }); + expect(schemaReads).toEqual(["tools.bench.org.main.record9999"]); + const found = decodeSearchItems(result.structuredContent).items[0]; + expect(found).toBeDefined(); + const invoked = await client.callTool({ + name: "invoke", + arguments: { tool: found?.id, arguments: {} }, + }); + expect(invoked.isError ?? false).toBe(false); + expect(executed).toEqual(['return await tools["bench.org.main.record9999"]({});']); + const page = await client.callTool({ + name: "search", + arguments: { query: "bench", limit: 2 }, + }); + expect(page.structuredContent).toMatchObject({ + total: 10000, + hasMore: true, + nextOffset: 2, + }); + expect(decodeSearchItems(page.structuredContent).items).toHaveLength(2); + expect(schemaReads).toHaveLength(4); + const next = await client.callTool({ + name: "search", + arguments: { query: "bench", limit: 2, offset: 2 }, + }); + expect(next.structuredContent).toMatchObject({ total: 10000, nextOffset: 4 }); + expect(next.structuredContent).not.toEqual(page.structuredContent); + const missing = await client.callTool({ + name: "search", + arguments: { query: "nonexistent quasar" }, + }); + expect(missing.structuredContent).toEqual({ + items: [], + total: 0, + hasMore: false, + nextOffset: null, + }); + }, + ); + }); + + it("uses current schemas and excludes static configuration tools", async () => { + const recording = makeRecordingEngine(); + const dynamic = projection({ + integration: "notes", + name: "create", + inputSchema: { type: "object" }, + }); + const catalog = [dynamic, projection({ integration: "settings", name: "erase", static: true })]; + let current: ToolSchemaView = { address: dynamic.address, inputSchema: dynamic.inputSchema }; + const tools: McpToolsPort = { ...toolPort(catalog), schema: () => Effect.succeed(current) }; + await withClient({ engine: recording.engine, mode: "passthrough", tools }, async (client) => { + const hidden = await client.callTool({ name: "search", arguments: { query: "settings" } }); + expect(decodeSearchItems(hidden.structuredContent).items).toEqual([]); + const staticCall = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.settings.org.main.erase", arguments: {} }, + }); + expect(staticCall.isError).toBe(true); + await client.callTool({ name: "search", arguments: { query: "notes" } }); + current = { + address: dynamic.address, + inputSchema: { + type: "object", + properties: { title: { type: "string" } }, + required: ["title"], + }, + }; + const invalid = await client.callTool({ + name: "invoke", + arguments: { tool: String(dynamic.address), arguments: {} }, + }); + expect(invalid.isError).toBe(true); + expect(recording.executed).toEqual([]); + const refreshed = await client.callTool({ name: "search", arguments: { query: "notes" } }); + expect(refreshed.structuredContent).toMatchObject({ + items: [{ inputSchema: { required: ["title"] } }], + }); + }); + }); + + it("returns schemas and account details from search and marks invoke destructive", async () => { + const { engine } = makeRecordingEngine(); + await withClient({ engine, mode: "passthrough", tools: toolPort(CATALOG) }, async (client) => { + const listed = await client.listTools(); + expect(listed.tools.find((tool) => tool.name === "search")?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(listed.tools.find((tool) => tool.name === "invoke")?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + }); + const result = await client.callTool({ + name: "search", + arguments: { query: "github issues create", limit: 1 }, + }); + expect(result.structuredContent).toMatchObject({ + items: [ + { + id: "tools.github.org.main.issues.create", + owner: "org", + connection: "main", + annotations: { requiresApproval: true }, + inputSchema: { + type: "object", + properties: { title: { type: "string" }, body: { $ref: "#/$defs/Body" } }, + required: ["title"], + $defs: { Body: { type: "string" } }, + }, + }, + ], + }); + }); + }); + + it("runs a call as one execute of single-call code, never a pause", async () => { + const recording = makeRecordingEngine({ ok: true, data: { number: 7 } }); + await withClient( + { + engine: recording.engine, + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const result = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "hello" } }, + }); + expect(recording.executed).toEqual([ + 'return await tools["github.org.main.issues.create"]({"title":"hello"});', + ]); + expect(recording.pausedCalls()).toBe(0); + // The tool's `data` is the result: nothing sits between the tool and + // the client to unwrap the `{ ok, data }` envelope for it. + expect(result.isError ?? false).toBe(false); + expect(result.structuredContent).toEqual({ + status: "completed", + result: { number: 7 }, + logs: [], + }); + }, + ); + }); + + it("surfaces an expected tool failure as an MCP error result", async () => { + const recording = makeRecordingEngine({ + ok: false, + error: { code: "tool_blocked", message: "Tool blocked by policy: github.org.main.x" }, + }); + await withClient( + { + engine: recording.engine, + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const result = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.issues.list", arguments: {} }, + }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ + status: "error", + error: { code: "tool_blocked", message: "Tool blocked by policy: github.org.main.x" }, + logs: [], + }); + const text = (result.content as Array<{ type: string; text?: string }>)[0]?.text ?? ""; + expect(text).toContain("tool_blocked"); + }, + ); + }); + + /** An engine whose tool raises the given elicitations in order and records + * each answer. `source` is what the executor stamps: `policy` for its own + * approval gate, `tool` for anything the tool asked for itself. */ + const elicitingEngine = ( + requests: ReadonlyArray<{ readonly source: "policy" | "tool"; readonly request: any }>, + seen: string[], + ): ExecutionEngine => ({ + ...makeRecordingEngine().engine, + execute: (_code, options) => + Effect.gen(function* () { + for (const { source, request } of requests) { + const answer = yield* options.onElicitation({ + address: CATALOG[0]!.address, + args: {}, + request, + source, + }); + seen.push(`${source}:${answer.action}`); + if (answer.action !== "accept") { + return { result: { ok: false, error: { code: "declined", message: "declined" } } }; + } + } + return { result: { ok: true, data: null } }; + }), + }); + + const approvalGate = { + _tag: "FormElicitation" as const, + message: "Approve github.org.main.issues.create?", + requestedSchema: { type: "object", properties: {} }, + }; + + it("accepts the executor's own approval gate inline", async () => { + const seen: string[] = []; + await withClient( + { + engine: elicitingEngine([{ source: "policy", request: approvalGate }], seen), + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const result = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "x" } }, + }); + expect(seen).toEqual(["policy:accept"]); + expect(result.isError ?? false).toBe(false); + }, + ); + }); + + it("keeps every invoke marked destructive even when the selected tool has no approval annotation", async () => { + const seen: string[] = []; + await withClient( + { + engine: elicitingEngine([{ source: "policy", request: approvalGate }], seen), + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const invoke = (await client.listTools()).tools.find((tool) => tool.name === "invoke"); + expect(invoke?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + const result = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.issues.list", arguments: {} }, + }); + expect(seen).toEqual(["policy:accept"]); + expect(result.isError ?? false).toBe(false); + }, + ); + }); + + it("never auto-accepts a tool-raised prompt, even one with an empty schema", async () => { + // Same wire shape as the approval gate, but raised by the TOOL: a + // per-site grant whose terms live in `meta`. Provenance, not shape, + // decides. With no elicitation capability on the client, the call fails + // and says so — it is not silently granted. + const seen: string[] = []; + const siteGrant = { + _tag: "FormElicitation" as const, + message: "Allow Browser use to access example.com?", + requestedSchema: {}, + meta: { persist: "always", origin: "https://example.com" }, + }; + await withClient( + { + engine: elicitingEngine([{ source: "tool", request: siteGrant }], seen), + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const result = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "x" } }, + }); + expect(seen).toEqual(["tool:decline"]); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + status: "error", + error: { code: "elicitation_unsupported", request: siteGrant.message }, + }); + }, + ); + }); + + it("reports an unanswerable URL request with the URL, not as a user decline", async () => { + const seen: string[] = []; + const reconnect = { + _tag: "UrlElicitation" as const, + message: "Reconnect GitHub", + url: "https://example.test/oauth/start", + elicitationId: "elic_1", + }; + await withClient( + { + engine: elicitingEngine([{ source: "tool", request: reconnect }], seen), + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const result = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.issues.create", arguments: { title: "x" } }, + }); + expect(seen).toEqual(["tool:decline"]); + expect(result.isError).toBe(true); + const text = (result.content as Array<{ text?: string }>)[0]?.text ?? ""; + expect(text).toContain("does not support elicitation"); + expect(text).toContain("https://example.test/oauth/start"); + expect(text).not.toContain("declined by the user"); + expect(result.structuredContent).toMatchObject({ + error: { code: "elicitation_unsupported", url: reconnect.url }, + }); + }, + ); + }); + + it("serves no artifact tools in passthrough even when artifacts are requested", async () => { + const { engine } = makeRecordingEngine(); + await withClient( + { + engine, + mode: "passthrough", + artifactsEnabled: true, + loadAppShellHtml: async () => "", + artifacts: { + list: () => Effect.succeed([]), + get: () => Effect.die("unused"), + save: () => Effect.die("unused"), + }, + tools: toolPort(CATALOG), + }, + async (client) => { + const names = (await client.listTools()).tools.map((tool) => tool.name); + expect(names.sort()).toEqual(["integrations", "invoke", "search", "skills"]); + }, + ); + }); + + it("rejects arguments that fail the advertised schema before running anything", async () => { + const recording = makeRecordingEngine(); + await withClient( + { + engine: recording.engine, + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const result = await client.callTool({ + name: "invoke", + arguments: { + tool: "tools.github.org.main.issues.create", + arguments: { body: "no title" }, + }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("Invalid arguments"); + expect(recording.executed).toEqual([]); + }, + ); + }); + + it("answers an unknown tool name with a not-found error", async () => { + const { engine } = makeRecordingEngine(); + await withClient( + { + engine, + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + const result = await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.nope", arguments: {} }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("not found"); + }, + ); + }); + + it("rejects empty searches, oversized pages and malformed invoke inputs", async () => { + const recording = makeRecordingEngine(); + await withClient( + { + engine: recording.engine, + mode: "passthrough", + tools: toolPort(CATALOG), + }, + async (client) => { + for (const arguments_ of [ + { query: " " }, + { query: "github", limit: 21 }, + { query: "github", offset: -1 }, + ]) { + expect((await client.callTool({ name: "search", arguments: arguments_ })).isError).toBe( + true, + ); + } + expect( + ( + await client.callTool({ + name: "invoke", + arguments: { tool: "tools.github.org.main.issues.create", arguments: "{}" }, + }) + ).isError, + ).toBe(true); + expect(recording.executed).toEqual([]); + }, + ); + }); + + it("leaves codemode untouched when the mode is absent", async () => { + const { engine } = makeRecordingEngine(); + await withClient( + { + engine, + description: "Execute TypeScript in a sandboxed runtime.", + tools: toolPort(CATALOG), + }, + async (client) => { + const names = (await client.listTools()).tools.map((tool) => tool.name); + expect(names).toContain("execute"); + expect(names).toContain("skills"); + expect(names).not.toContain("github__issues_create"); + }, + ); + }); + + it("fails the build, not the session, when the host provides no catalog", async () => { + const { engine } = makeRecordingEngine(); + const outcome = await Effect.runPromise( + createExecutorMcpServer({ engine, mode: "passthrough" }).pipe(Effect.flip), + ); + expect(outcome).toBeInstanceOf(McpPassthroughUnavailableError); + }); +}); diff --git a/packages/hosts/mcp/src/passthrough-tools.ts b/packages/hosts/mcp/src/passthrough-tools.ts new file mode 100644 index 0000000000..44e3f3705d --- /dev/null +++ b/packages/hosts/mcp/src/passthrough-tools.ts @@ -0,0 +1,54 @@ +import type { Skill } from "@executor-js/execution"; + +/** + * The sandbox code a passthrough call runs. Built HERE from the session's + * resolved address and a JSON-encoded argument — never concatenated from raw + * model input — and shaped exactly like the artifact `execute-action` grammar + * (`return await tools.()`), so it takes the same engine path as + * every other execution: billing, rate limits, shape memory and analytics all + * see it as one execution. + */ +export const passthroughCallCode = (address: string, args: unknown): string => { + // The whole dotted address is ONE JSON string literal in bracket notation: + // `tools["github.org.main.items.then"](...)`. Two reasons it is not a chain + // of property accesses. The tool segment is customer-controlled (an OpenAPI + // spec may set `x-executor-toolPath`), so it must be data in the generated + // source, never syntax. And every sandbox proxy reserves the property name + // `then` (a thenable check would otherwise await the proxy itself), so a + // per-segment chain could never reach a tool whose path contains `then`. + // Each proxy joins the accessed keys with `.` to form the dispatch path, so + // a single key holding the dotted address reassembles to exactly the same + // path the chain would have. + const bare = address.startsWith("tools.") ? address.slice("tools.".length) : address; + return `return await tools[${JSON.stringify(bare)}](${JSON.stringify(args ?? {})});`; +}; + +/** Describe the fixed search/invoke surface without listing the underlying catalog. */ +export const passthroughInstructions = (): string => + 'Use integrations to see connected accounts and skills({ name: "search-invoke" }) for the workflow. ' + + "Find connected integration tools with search, then call invoke with the returned tool ID and JSON arguments. " + + "Search returns input schemas and account details. Use its nextOffset to get more matches. " + + "Invoke can change external state; your client handles approval for each call. Workspace block policies remain enforced. " + + "No JavaScript, execute, resume, or artifact tools are exposed in this mode."; + +/** On-demand guidance for the JSON tool surface; no sandbox or artifact instructions. */ +export const SEARCH_INVOKE_SKILL: Skill = { + name: "search-invoke", + summary: "Discover connected accounts, search for actions, and invoke tools with JSON arguments.", + body: [ + "# Search and invoke", + "", + "1. Call `integrations({})` to see connected integrations and accounts. Each item includes an integration description, account label, and last recorded health. A null health verdict means the account has not been checked; a saved connection does not guarantee a working credential.", + '2. Call `search({ query: "create issue", integration: "github", owner: "org", connection: "main" })`. Use the exact integration, owner, and connection returned by integrations to select an account. Omit filters to search across accounts visible to you.', + "3. Read the matching tool's `inputSchema`. Call `invoke({ tool: , arguments: })`. Do not guess tool IDs or arguments.", + "", + "## Pagination", + "Both integrations and search return `{ items, total, hasMore, nextOffset }`. If hasMore is true, repeat the call with the same filters and `offset: nextOffset`. Search also needs the same query. Search returns at most 20 tools per page, with schemas only for those matches.", + "Tool-search pagination is separate from an upstream API's pagination. Follow the invoked tool's schema and response for cursor or page arguments when retrieving more records.", + "", + "## Results and approval", + "Invoke forwards the tool's result, including supported MCP content. Check `isError` and any returned error before treating a call as successful. Your client handles approval for invoke; workspace block policies still apply. An upstream request for user input needs a client that supports native elicitation.", + "If a tool is no longer available, search again. If an account needs authentication, ask the user to reconnect it in Executor. Never ask for credentials in chat.", + "This mode accepts JSON tool arguments. It does not expose execute, resume, or artifact tools. The skills tool serves only this server's guides, not files or skills from your harness or project.", + ].join("\n"), +}; diff --git a/packages/hosts/mcp/src/tool-call-code.ts b/packages/hosts/mcp/src/tool-call-code.ts index b95933dcd1..852791a6c0 100644 --- a/packages/hosts/mcp/src/tool-call-code.ts +++ b/packages/hosts/mcp/src/tool-call-code.ts @@ -12,13 +12,13 @@ * the shell ever writes any. So the server parses `execute-action` against the * one grammar the proxy emits: * - * return await tools.("")?(.)*() + * return await tools("")?*() * * One awaited tool call, one JSON-literal argument, nothing else — no * statements, no loops, no composition. `execute` (the model-facing codemode * tool) is untouched; this constraint is only for the app-originated channel. * - * The leading identifier is an INTEGRATION, not a connection: artifact paths + * The leading segment is an INTEGRATION, not a connection: artifact paths * carry no tier and no connection name (see `artifact-bindings.ts`). The * optional string call right after it is the integration ROLE, which is how an * artifact using two accounts of one integration says which it means. Both are @@ -32,16 +32,26 @@ import { Option, Schema } from "effect"; -const TOOL_CALL_CODE = - /^return await tools\.([A-Za-z_$][\w$]*)(?:\((("(?:[^"\\]|\\.)*"))\))?((?:\.[A-Za-z_$][\w$]*)*)\((.*)\);?$/s; +const JSON_STRING_LITERAL = String.raw`"(?:[^"\\]|\\.)*"`; +const IDENTIFIER = String.raw`[A-Za-z_$][\w$]*`; +const SLUG = String.raw`[A-Za-z_$][\w$-]*`; +const PATH_SEGMENT = String.raw`(?:\.${IDENTIFIER}|\["${SLUG}"\])`; +const TOOL_CALL_CODE = new RegExp( + String.raw`^return await tools(${PATH_SEGMENT})(?:\((${JSON_STRING_LITERAL})\))?((?:${PATH_SEGMENT})*)\((.*)\);?$`, + "s", +); +const PATH_SEGMENT_MATCHER = new RegExp(String.raw`(?:\.(${IDENTIFIER})|\["(${SLUG})"\])`, "g"); /** The proxy's argument is always `JSON.stringify` output, so anything that * does not decode is, by construction, not something the proxy emitted. */ const decodeArgs = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); const decodeRole = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String)); +const decodePath = (serialized: string): readonly string[] => + Array.from(serialized.matchAll(PATH_SEGMENT_MATCHER), (match) => match[1] ?? match[2] ?? ""); + export type ParsedToolCall = { - /** The dotted path segments under `tools`, e.g. `["github", "issues", "create"]`. + /** The path segments under `tools`, e.g. `["github", "issues", "create"]`. * The head is an integration slug (or a system-tool root); it is never a * tier or a connection name. */ readonly path: readonly string[]; @@ -56,7 +66,7 @@ export type ParsedToolCall = { /** The message handed back to the iframe when its code is not a tool call. */ export const TOOL_CALL_CONTRACT_MESSAGE = [ "execute-action accepts a single tool call, not arbitrary code.", - 'The only accepted form is `return await tools.("")?.()` —', + 'The only accepted form is `return await tools("")?()` —', "exactly what the shell's `tools.*` proxy emits.", "Interactive UI reaches integrations declaratively:", "`tools...queryOptions(...)` / `.infiniteQueryOptions(...)` for reads,", @@ -72,14 +82,14 @@ export const parseToolCallCode = (code: string): ParsedToolCall | null => { const match = TOOL_CALL_CODE.exec(code.trim()); if (!match) return null; - const [, root, serializedRole, , dottedRest, serializedArgs] = match; - if (root === undefined || dottedRest === undefined || serializedArgs === undefined) return null; + const [, root, serializedRole, serializedRest, serializedArgs] = match; + if (root === undefined || serializedRest === undefined || serializedArgs === undefined) + return null; const args = decodeArgs(serializedArgs); if (Option.isNone(args)) return null; - const rest = dottedRest.length > 0 ? dottedRest.slice(1).split(".") : []; - const path = [root, ...rest]; + const path = decodePath(`${root}${serializedRest}`); if (serializedRole === undefined) return { path, args: args.value }; @@ -91,7 +101,11 @@ export const parseToolCallCode = (code: string): ParsedToolCall | null => { return { path, role: role.value, args: args.value }; }; -const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; +const TOOL_PATH_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$-]*$/; + +const formatToolPathSegment = (segment: string): string => + TOOL_PATH_IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`; /** * Build the codemode call for a RESOLVED address — the full @@ -115,5 +129,5 @@ export const formatToolCallCode = (path: readonly string[], args: unknown): stri throw new Error("Invalid resolved tool path."); } } - return `return await tools.${path.join(".")}(${JSON.stringify(args ?? {})})`; + return `return await tools${path.map(formatToolPathSegment).join("")}(${JSON.stringify(args ?? {})})`; }; diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 1b7bb8aa45..04fd4fca28 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -197,9 +197,11 @@ const toolFile = (input: { /** Build an engine whose execute triggers one elicitation and returns the handler's result. */ const makeElicitingEngine = ( request: FormElicitation | UrlElicitation, - formatResult: (response: { action: string; content?: Record }) => unknown = ( - r, - ) => r.action, + formatResult: (response: { + action: string; + content?: Record; + meta?: { readonly persist?: string }; + }) => unknown = (r) => r.action, ): ExecutionEngine => makeStubEngine({ execute: (_code, { onElicitation }) => @@ -1039,12 +1041,15 @@ describe("MCP host server — native form-only elicitation", () => { // --------------------------------------------------------------------------- describe("MCP host server — client without elicitation (pause/resume)", () => { - it("completed execution returns result directly", async () => { + it("completed execution returns result and connected-tool metadata directly", async () => { const engine = makeStubEngine({ executeWithPause: () => Effect.succeed({ status: "completed", - result: { result: "done" }, + result: { + result: "done", + toolPaths: ["linear.org.work.issues.list"], + }, }), }); @@ -1054,6 +1059,10 @@ describe("MCP host server — client without elicitation (pause/resume)", () => arguments: { code: "ok" }, }); expect(result.content).toEqual([{ type: "text", text: "done" }]); + expect(result.structuredContent).toMatchObject({ + status: "completed", + toolName: "linear.org.work.issues.list", + }); expect(result.isError).toBeFalsy(); }); }); @@ -1653,6 +1662,88 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }); }); +// --------------------------------------------------------------------------- +// Approval terms — the request's ride out as `_meta`, the answer's ride back +// --------------------------------------------------------------------------- + +describe("MCP host server — approval terms", () => { + const appApproval = FormElicitation.make({ + message: 'Allow Computer Use to use "Finder"?', + requestedSchema: {}, + meta: { persist: ["session", "always"], connector_name: "Computer Use" }, + }); + + // The engine hands the response back as the execution's result, so the + // structured output carries it verbatim. + const responseOf = (structuredContent: unknown): unknown => + (structuredContent as { readonly result: unknown }).result; + + it("native mode shows the client the offered scopes and returns the one it chose", async () => { + const engine = makeElicitingEngine(appApproval, (r) => r); + let seen: unknown; + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + seen = request.params._meta; + return { action: "accept" as const, content: {}, _meta: { persist: "always" } }; + }); + + const result = await client.callTool({ name: "execute", arguments: { code: "finder" } }); + expect(seen).toEqual({ persist: ["session", "always"], connector_name: "Computer Use" }); + expect(responseOf(result.structuredContent)).toEqual({ + action: "accept", + content: {}, + meta: { persist: "always" }, + }); + }); + }); + + it("native mode invents no terms when the client states none", async () => { + const engine = makeElicitingEngine(appApproval, (r) => r); + + await withNativeClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async () => ({ + action: "accept" as const, + content: {}, + })); + + const result = await client.callTool({ name: "execute", arguments: { code: "finder" } }); + expect(responseOf(result.structuredContent)).toEqual({ action: "accept", content: {} }); + }); + }); + + it("model mode passes the resume tool's persist choice to the engine", async () => { + const received: unknown[] = []; + const engine = makeStubEngine({ + resume: (_id, response) => + Effect.sync(() => { + received.push(response); + return { status: "completed", result: { result: "ok" } }; + }), + }); + + await withClient( + engine, + NO_CAPS, + async (client) => { + await client.callTool({ + name: "resume", + arguments: { executionId: "exec_1", action: "accept", persist: "session" }, + }); + await client.callTool({ + name: "resume", + arguments: { executionId: "exec_2", action: "accept" }, + }); + expect(received).toEqual([ + { action: "accept", content: undefined, meta: { persist: "session" } }, + { action: "accept", content: undefined }, + ]); + }, + { elicitationMode: { mode: "model" } }, + ); + }); +}); + // --------------------------------------------------------------------------- // Elicitation error handling // --------------------------------------------------------------------------- diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 730c5bfd89..0255ff582e 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1,3 +1,4 @@ +import { reattachDefs } from "@executor-js/sdk/host-internal"; import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect"; import * as Cause from "effect/Cause"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -22,25 +23,36 @@ import * as z from "zod/v4"; import { CurrentOrgWriteAccess, + ToolAddress, + IntegrationSlug, + ConnectionName, + parseToolAddress, isToolFile, + isToolResult, makeOrgWriteAccessState, sanitizeArtifactPreviewMarkup, type OrgWriteAccess, } from "@executor-js/sdk"; import type { Artifact, + Connection, + Integration, ArtifactBinding, ArtifactSummary, ElicitationResponse, + ElicitationResponseMeta, ElicitationHandler, ElicitationContext, ElicitationRequest, SaveArtifactInput, ToolFileValue, + Executor, + ToolSchemaView, } from "@executor-js/sdk"; import type * as Tracer from "effect/Tracer"; import { createExecutionEngine, + searchTools, formatExecuteResult, formatPausedExecution, formatTtlDuration, @@ -74,6 +86,12 @@ import { type BindableConnection, } from "./artifact-bindings"; import { MCP_ORG_WRITE_ACCESS_HEADER } from "./seams"; +import { + passthroughCallCode, + passthroughInstructions, + SEARCH_INVOKE_SKILL, +} from "./passthrough-tools"; +import type { McpToolMode } from "./browser-approval"; // --------------------------------------------------------------------------- // Workers-compatible JSON Schema validator (replaces Ajv which uses new Function()) @@ -180,9 +198,26 @@ type SharedMcpServerConfig = { * the `execute` description lists). The tools exist to carry the namespaces * into the model's context as tool names; each call routes through the same * execution flow as `tools.search({ namespace })` inside `execute`, so the - * results match what code-side search returns. + * results match what code-side search returns. Codemode only: passthrough + * ignores it (it has its own search tool, and codemode search + * results point at an `execute` tool passthrough does not serve). */ readonly searchToolsEnabled?: boolean; + /** + * The tool surface this connection serves. `codemode` (the default) is the + * `execute` tool plus `skills`/`resume` and the artifact surface. + * `passthrough` (`?mode=passthrough`) serves search and invoke for the + * visible catalog, with no execute, skills, resume, or artifact tools. + * Invoke is marked destructive for client approval. Requires `tools`. + */ + readonly mode?: McpToolMode; + /** + * The scoped executor's tool catalog, for passthrough mode. Structurally + * satisfied by `executor.tools`. Hosts that never serve passthrough may + * leave it unset; a passthrough session without it fails at build time + * rather than silently serving an empty surface. + */ + readonly tools?: McpToolsPort; /** * Renders an artifact once, server-side, before it is saved — so a component * that throws on its first render is refused at create time with the real @@ -207,14 +242,16 @@ type SharedMcpServerConfig = { */ readonly artifacts?: McpArtifactsPort; /** - * The caller's saved connections, for binding an artifact's integration roles - * at create time. Structurally satisfied by `executor.connections`; hosts pass + * The caller's saved connections, for the search/invoke account inventory + * and binding artifact integration roles at create time. Structurally satisfied by `executor.connections`; hosts pass * the same scoped executor they pass `artifacts`. * * Absent means `create-artifact` cannot bind, so it refuses code that calls an * integration rather than saving an artifact that could never run. */ readonly connections?: McpConnectionsPort; + /** Scoped integration metadata for the search/invoke account inventory. */ + readonly integrations?: McpIntegrationsPort; /** * Builds the web-app deep link for a saved artifact. Clients that can't * render MCP Apps get this URL instead of an inline widget. Absent (stdio has @@ -264,14 +301,35 @@ export type McpArtifactsPort = { }; /** - * The connection surface binding needs: list what this caller can reach. The + * The connection surface binding and discovery need: list what this caller can reach. The * scoped executor has already narrowed it, so an inferred binding can never * name a connection the caller couldn't call themselves. */ export type McpConnectionsPort = { - readonly list: () => Effect.Effect; + readonly list: () => Effect.Effect< + readonly (BindableConnection & + Pick)[], + unknown + >; +}; + +/** Catalog metadata visible to this caller; no tool schemas or credentials. */ +export type McpIntegrationsPort = { + readonly list: () => Effect.Effect< + readonly Pick[], + unknown + >; }; +/** The same list and schema APIs used by codemode discovery. */ +export type McpToolsPort = Pick; + +/** A passthrough session was requested but the host gave the factory no + * catalog to serve. A configuration defect, not a runtime condition. */ +export class McpPassthroughUnavailableError extends Data.TaggedError( + "McpPassthroughUnavailableError", +)<{ readonly reason: string }> {} + export type ExecutorMcpServerConfig = | (ExecutionEngineConfig & SharedMcpServerConfig) | ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig) @@ -380,6 +438,9 @@ const elicitationRequestUrl = (request: ElicitationRequest): string | undefined const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => elicitationRequestTag(request); +// The request's terms travel as `_meta`, the way they arrived: a native +// client that renders "Allow Computer Use to use Finder?" needs to see that +// accepting can be remembered, and which scopes it may answer with. const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams = Match.type().pipe( Match.tag("UrlElicitation", (req) => ({ @@ -387,6 +448,7 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa message: req.message, url: req.url, elicitationId: req.elicitationId, + ...(req.meta === undefined ? {} : { _meta: req.meta }), })), Match.tag("FormElicitation", (req) => ({ message: req.message, @@ -397,10 +459,19 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa Object.keys(req.requestedSchema).length === 0 ? { type: "object" as const, properties: {} } : req.requestedSchema, + ...(req.meta === undefined ? {} : { _meta: req.meta }), })), Match.exhaustive, ); +/** The client's answer to the terms: the `persist` scope it chose, read from + * the result's `_meta` — and nothing else, so an answer states no more than + * `ElicitationResponseMeta` names. */ +const answeredTerms = (meta: unknown): ElicitationResponseMeta | undefined => { + const persist = isRecord(meta) ? meta["persist"] : undefined; + return typeof persist === "string" ? { persist } : undefined; +}; + const makeMcpElicitationHandler = ( server: McpServer, @@ -443,6 +514,7 @@ const makeMcpElicitationHandler = { relatedRequestId }, ); + const meta = answeredTerms(response._meta); debugLog?.("elicitation.response", { requestTag, action: response.action, @@ -450,11 +522,13 @@ const makeMcpElicitationHandler = typeof response.content === "object" && response.content !== null && Object.keys(response.content).length > 0, + persist: meta?.persist, }); return { action: response.action as typeof ElicitationResponse.Type.action, content: response.content, + ...(meta === undefined ? {} : { meta }), }; }).pipe( Effect.tapDefect((defect) => @@ -638,6 +712,64 @@ const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { }; }; +/** + * A passthrough call's result IS the tool's `ToolResult`. Inside `execute` + * the model reads `{ ok, data | error }` and branches; here nothing runs + * between the tool and the client, so an expected failure (`ok: false` — a + * 4xx wall, a blocked policy, a validation miss) has to be an MCP error + * result, and a success unwraps to the tool's `data`. Everything else + * (sandbox error, emitted output) keeps the codemode rendering. + */ +const toPassthroughResult = (outcome: FormattedExecuteInput): McpToolResult => { + const value = outcome.result; + if (outcome.error || !isToolResult(value)) return toMcpResult(outcome); + if (value.ok) { + return toMcpResult({ ...outcome, result: value.data }); + } + const message = `${value.error.code}: ${value.error.message}`; + return { + content: [{ type: "text", text: `Error: ${message}` }], + structuredContent: { + status: "error", + error: value.error, + logs: outcome.logs ?? [], + }, + isError: true, + }; +}; + +/** + * A passthrough tool asked the user for something and the connected client + * advertises no elicitation capability, so nobody could answer. Say exactly + * that, and carry the request — a reconnect/OAuth URL is the usual content — + * so the model can relay it and the user can act outside the client. + */ +const elicitationUnsupportedResult = ( + toolName: string, + request: ElicitationRequest, +): McpToolResult => { + const url = elicitationRequestUrl(request); + const lines = [ + `Tool ${toolName} needs input from the user, but this MCP client does not support elicitation, so the call could not complete.`, + `Request: ${request.message}`, + ...(url ? [`Open this URL to continue, then retry the call: ${url}`] : []), + ]; + return { + content: [{ type: "text", text: `Error: ${lines.join("\n")}` }], + structuredContent: { + status: "error", + error: { + code: "elicitation_unsupported", + message: lines[0]!, + request: request.message, + ...(url ? { url } : {}), + }, + logs: [], + }, + isError: true, + }; +}; + const toMcpPausedResult = (formatted: ReturnType): McpToolResult => ({ content: [{ type: "text", text: formatted.text }], structuredContent: formatted.structured, @@ -900,6 +1032,8 @@ const startMarker = (name: string, attributes: Record): Effect. // user as an inline widget when the client renders MCP Apps, and as a link into // the web app when it doesn't. Both carry `artifactId`, because either way the // artifact was saved and can be reopened later. +// `show-artifact` returns source on both channels; create/edit only confirm +// saves. const renderRejectedResult = (reason: string): McpToolResult => ({ content: [{ type: "text", text: `create-artifact rejected: ${reason}` }], @@ -983,6 +1117,24 @@ const bindingUnresolvedResult = (input: { isError: true, }); +/** Format the stored source for the text result channel. */ +const artifactSourceText = (code: string): string => `Source:\n\`\`\`tsx\n${code}\n\`\`\``; + +/** Add source to both MCP result channels. */ +const withArtifactSource = (result: McpToolResult, code: string): McpToolResult => { + const source = artifactSourceText(code); + const content = result.content.map((block, index) => + index === 0 && block.type === "text" + ? { type: "text" as const, text: `${block.text}\n\n${source}` } + : block, + ); + return { + ...result, + content, + structuredContent: { ...result.structuredContent, code }, + }; +}; + const renderedInAppResult = (input: { readonly code: string; readonly artifactId: string; @@ -1104,13 +1256,272 @@ const parseJsonContent = (raw: string): Record | undefined => { return Option.isSome(parsed) ? parsed.value : undefined; }; +// --------------------------------------------------------------------------- +// Passthrough surface +// --------------------------------------------------------------------------- + +/** Serialize one existing schema view as a self-contained MCP input schema. */ +const passthroughInputSchema = (view: ToolSchemaView): unknown => + reattachDefs( + view.inputSchema ?? { type: "object", properties: {} }, + new Map(Object.entries(view.schemaDefinitions ?? {})), + ); + +/** Register discovery over the existing APIs, with no catalog work at connection time. */ +const registerPassthroughTools = ( + server: McpServer, + tools: McpToolsPort, + connections: McpConnectionsPort, + integrations: McpIntegrationsPort, + run: ( + address: ToolAddress, + args: unknown, + extra: McpRequestJoinKeys, + ) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const context = yield* Effect.context(); + const validator = new CfWorkerJsonSchemaValidator(); + const boundary = ( + effect: Effect.Effect, + extra: McpRequestJoinKeys, + ) => + Effect.runPromiseWith(context)( + effect.pipe( + Effect.provideService( + CurrentOrgWriteAccess, + makeOrgWriteAccessState(requestOrgWriteAccess(extra)), + ), + Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))), + ), + ); + yield* Effect.sync(() => { + server.registerTool( + "integrations", + { + description: + "List connected integrations and accounts visible to you. Returns integration descriptions, account labels, exact search filters, and last recorded health (null means unchecked). One item per account; use nextOffset for more. Does not load tool schemas or check credentials.", + inputSchema: { + integration: z.string().trim().min(1).optional().describe("Exact integration slug."), + owner: z.enum(["org", "user"]).optional(), + limit: z.number().int().min(1).max(50).default(20), + offset: z.number().int().min(0).default(0), + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + ({ integration, owner, limit, offset }, extra) => + boundary( + Effect.gen(function* () { + const [accounts, catalog] = yield* Effect.all([ + connections.list(), + integrations.list(), + ]); + const metadata = new Map(catalog.map((item) => [String(item.slug), item])); + const visible = accounts + .flatMap((account) => { + const item = metadata.get(account.integration); + if ( + !item || + (integration !== undefined && account.integration !== integration) || + (owner !== undefined && account.owner !== owner) + ) + return []; + return [ + { + integration: account.integration, + integrationName: item.name, + integrationDescription: item.description, + owner: account.owner, + connection: account.name, + identityLabel: account.identityLabel ?? null, + description: account.description ?? null, + lastHealth: + account.lastHealth == null + ? null + : { + status: account.lastHealth.status, + checkedAt: account.lastHealth.checkedAt, + }, + }, + ]; + }) + .sort( + (a, b) => + a.integration.localeCompare(b.integration) || + a.owner.localeCompare(b.owner) || + a.connection.localeCompare(b.connection), + ); + const items = visible.slice(offset, offset + limit); + const hasMore = offset + items.length < visible.length; + const result = { + items, + total: visible.length, + hasMore, + nextOffset: hasMore ? offset + items.length : null, + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + }), + extra, + ), + ); + server.registerTool( + "search", + { + description: + "Search connected integration tools by action, integration, or account. Returns matching tool IDs, account details, and full JSON input schemas. Pass the returned ID and arguments to invoke. Use integrations to discover accounts, then pass exact integration, owner, and connection filters. Use nextOffset to page through matches.", + inputSchema: { + query: z + .string() + .trim() + .min(1) + .max(500) + .describe("Keywords describing the tool or task, such as github create issue."), + integration: z + .string() + .trim() + .min(1) + .optional() + .describe("Exact integration slug from integrations."), + owner: z.enum(["org", "user"]).optional(), + connection: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Exact account name from integrations; pair with integration and owner to select one account.", + ), + limit: z.number().int().min(1).max(20).default(10), + offset: z.number().int().min(0).default(0), + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + ({ query, integration, owner, connection, limit, offset }, extra) => + boundary( + Effect.gen(function* () { + const discovery = { + tools: { + list: (filter?: Parameters[0]) => + tools + .list({ + ...filter, + ...(integration === undefined + ? {} + : { integration: IntegrationSlug.make(integration) }), + ...(owner === undefined ? {} : { owner }), + ...(connection === undefined + ? {} + : { connection: ConnectionName.make(connection) }), + }) + .pipe(Effect.map((items) => items.filter((tool) => tool.static !== true))), + }, + }; + const page = yield* searchTools(discovery, query, limit, { offset }); + const candidates = yield* Effect.forEach( + page.items, + (match) => + Effect.gen(function* () { + const address = ToolAddress.make(`tools.${match.path}`); + const identity = parseToolAddress(String(address)); + if (!identity) return null; + const schema = yield* tools.schema(address); + // Visibility can change between listing and schema lookup. + if (!schema) return null; + return { + id: String(address), + name: match.name, + integration: identity.integration, + owner: identity.owner, + connection: identity.connection, + description: match.description, + inputSchema: passthroughInputSchema(schema), + ...(schema.annotations ? { annotations: schema.annotations } : {}), + }; + }), + { concurrency: 4 }, + ); + const result = { ...page, items: candidates.filter(Predicate.isNotNull) }; + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + }), + extra, + ), + ); + server.registerTool( + "invoke", + { + description: + "Call one connected integration tool using the exact ID and JSON input schema returned by search. May read or change external state. Your client handles approval for this call; workspace blocks remain enforced.", + inputSchema: { + tool: z.string().min(1).describe("Exact tool ID returned by search."), + arguments: z + .record(z.string(), z.unknown()) + .describe("Tool arguments matching the inputSchema returned by search."), + }, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + ({ tool: id, arguments: args }, extra) => + boundary( + Effect.gen(function* () { + const identity = parseToolAddress(id); + const unavailable = { + isError: true, + content: [ + { + type: "text" as const, + text: "Tool not found or blocked by policy. Search for an available tool.", + }, + ], + }; + if (!identity) return unavailable; + const address = ToolAddress.make(id); + // Use the existing visibility filter and exclude static configuration tools. + const visible = yield* tools.list({ + integration: identity.integration, + owner: identity.owner, + connection: identity.connection, + query: String(identity.tool), + includeAnnotations: false, + }); + if (!visible.some((tool) => tool.static !== true && tool.address === address)) + return unavailable; + const schema = yield* tools.schema(address); + if (!schema) return unavailable; + // The SDK validator checks this dynamic JSON schema at the MCP boundary. + const validate = validator.getValidator( + passthroughInputSchema(schema) as JsonSchemaType, + ); + const checked = validate(args); + if (!checked.valid) + return { + isError: true, + content: [ + { + type: "text" as const, + text: `Invalid arguments for tool ${id}: ${checked.errorMessage ?? "invalid"}`, + }, + ], + }; + return yield* run(address, checked.data, extra); + }), + extra, + ), + ); + }); + }).pipe(Effect.withSpan("mcp.host.register_search_invoke")); + // --------------------------------------------------------------------------- // Server factory // --------------------------------------------------------------------------- export const createExecutorMcpServer = ( config: ExecutorMcpServerConfig, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const engine = "engine" in config ? config.engine : createExecutionEngine(config); const description = @@ -1122,11 +1533,28 @@ export const createExecutorMcpServer = ( // Artifacts are on unless this connection opted out (`?artifacts=false`). // One flag decides the whole surface: the tools, the shell resource, and // the skills catalog below. - const artifactsEnabled = config.artifactsEnabled ?? true; - const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); + // Search/invoke serves no artifact tools: artifacts run sandboxed code. + const artifactsEnabled = + config.mode === "passthrough" ? false : (config.artifactsEnabled ?? true); + const skillCatalog: readonly Skill[] = + config.mode === "passthrough" + ? [SEARCH_INVOKE_SKILL] + : skillCatalogFor({ artifacts: artifactsEnabled }); // Per-integration search tools are off unless this connection opted in // (`?search_tools=true`). const searchToolsEnabled = config.searchToolsEnabled ?? false; + // Passthrough (`?mode=passthrough`) replaces the codemode surface + // wholesale. The flag is read once here and every codemode-only + // registration below is gated on it, so the two surfaces cannot leak into + // each other. + const mode: McpToolMode = config.mode ?? "codemode"; + const passthrough = mode === "passthrough"; + if (passthrough && (!config.tools || !config.connections || !config.integrations)) { + return yield* new McpPassthroughUnavailableError({ + reason: + "passthrough mode requires tool list/schema, connection list, and integration list APIs", + }); + } // Captured at construction time. SDK callbacks fire later (often // deferred past the outer Effect's await), so we use the runtime to @@ -1227,6 +1655,11 @@ export const createExecutorMcpServer = ( // per host. capabilities: { resources: {}, tools: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), + ...(passthrough + ? { + instructions: passthroughInstructions(), + } + : {}), }, ), ).pipe(Effect.withSpan("mcp.host.create_server")); @@ -1409,8 +1842,7 @@ export const createExecutorMcpServer = ( const resumeExecution = ( executionId: string, - action: "accept" | "decline" | "cancel", - content: Record | undefined, + response: ResumeResponse, extra: McpRequestJoinKeys, ): Effect.Effect => Effect.gen(function* () { @@ -1420,17 +1852,18 @@ export const createExecutorMcpServer = ( }); debugLog("resume.call", { executionId, - action, - hasContent: content !== undefined, + action: response.action, + hasContent: response.content !== undefined, + persist: response.meta?.persist, clientCapabilities: server.server.getClientCapabilities() ?? null, }); - const outcome = yield* resumeWithLifecycle(executionId, { action, content }); + const outcome = yield* resumeWithLifecycle(executionId, response); if (!outcome) { debugLog("resume.missing_execution", { executionId }); if (yield* localExecutionAlreadySettled(executionId)) { return alreadySettledResult(executionId); } - const fallback = yield* resumeFallback(executionId, { action, content }); + const fallback = yield* resumeFallback(executionId, response); if (fallback) { debugLog("resume.fallback_result", { executionId, status: fallback.status }); return fallbackOutcomeResult(executionId, fallback); @@ -1454,7 +1887,7 @@ export const createExecutorMcpServer = ( Effect.withSpan("mcp.host.tool.resume", { attributes: { "mcp.tool.name": "resume", - "mcp.execute.resume.action": action, + "mcp.execute.resume.action": response.action, "mcp.execute.execution_id": executionId, }, }), @@ -1545,39 +1978,111 @@ export const createExecutorMcpServer = ( Effect.annotateSpans(joinKeyAttributes(extra)), ); + // --- passthrough call path --- + // + // Invoke runs one generated call through the existing execution engine. + // The client approves the generic destructive tool; upstream prompts use + // native elicitation, or fail with an actionable result when unsupported. + const executePassthroughCall = ( + address: ToolAddress, + args: unknown, + extra: McpRequestJoinKeys, + ): Effect.Effect => + Effect.gen(function* () { + yield* startMarker("mcp.host.tool.execute.start", { + "mcp.tool.id": String(address), + "mcp.tool.mode": "passthrough", + "executor.tool.address": address, + }); + const { url: supportsUrl } = getElicitationSupport(server); + const native = makeMcpElicitationHandler(server, extra.requestId, debugLog); + const { form: supportsForm } = getElicitationSupport(server); + // Set when the tool asked the user for something this client cannot + // relay. The handler has no error channel (a non-accept is a decline + // to the executor), so the request is kept here and the whole call is + // reported as unanswerable below — with what was asked, URL included — + // instead of as "declined by the user", which nobody did. + let unanswerable: ElicitationRequest | undefined; + const onElicitation: ElicitationHandler = (ctx) => { + // Every invoke is advertised as destructive, so the client's native + // approval covers the selected ID and arguments, even if policy changed. + // Tool-raised prompts still require their own response below. + if (ctx.source === "policy") { + return Effect.succeed({ action: "accept" as const, content: {} }); + } + // Anything the tool itself asked for goes to the client natively + // when it can take it; the native bridge already turns a URL + // request into a form for form-only clients. + if (supportsForm || (supportsUrl && Predicate.isTagged(ctx.request, "UrlElicitation"))) { + return native(ctx); + } + unanswerable = ctx.request; + return Effect.succeed({ action: "decline" as const }); + }; + const outcome = yield* engine.execute(passthroughCallCode(address, args), { + onElicitation, + }); + if (unanswerable) return elicitationUnsupportedResult(String(address), unanswerable); + return toPassthroughResult(outcome); + }).pipe( + Effect.withSpan("mcp.host.tool.execute", { + attributes: { + "mcp.tool.id": String(address), + "mcp.tool.mode": "passthrough", + "executor.integration": parseToolAddress(String(address))?.integration, + }, + }), + Effect.annotateSpans(joinKeyAttributes(extra)), + ); + // --- tools --- - yield* Effect.sync(() => - server.registerTool( - "execute", - { - description, - inputSchema: { code: z.string().trim().min(1) }, - }, - ({ code }, extra) => runToolEffect(executeCode(code, extra), extra), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "execute" }, - }), - ); + // Passthrough serves search and invoke in place of the codemode tools. + if (passthrough && config.tools && config.connections && config.integrations) { + yield* registerPassthroughTools( + server, + config.tools, + config.connections, + config.integrations, + executePassthroughCall, + ); + } + + if (!passthrough) + yield* Effect.sync(() => + server.registerTool( + "execute", + { + description, + inputSchema: { code: z.string().trim().min(1) }, + }, + ({ code }, extra) => runToolEffect(executeCode(code, extra), extra), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "execute" }, + }), + ); yield* Effect.sync(() => server.registerTool( "skills", { - description: [ - "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", - "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", - 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', - "Call with no name to list the few docs available.", - ].join("\n"), + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + description: passthrough + ? 'Documentation for this server only, not harness or project skills. Call with no name to list guides, or skills({ name: "search-invoke" }) for account discovery, tool search, invocation, and pagination.' + : [ + "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", + "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", + 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', + "Call with no name to list the few docs available.", + ].join("\n"), inputSchema: { name: z .string() .optional() .describe( - 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.', + `A doc from this server's own catalog, e.g. "${passthrough ? "search-invoke" : "execute"}". Omit to list the catalog.`, ), }, }, @@ -1590,58 +2095,73 @@ export const createExecutorMcpServer = ( }), ); - yield* Effect.sync(() => { - if (elicitationMode.mode === "native") { - return undefined; - } + if (!passthrough) + yield* Effect.sync(() => { + if (elicitationMode.mode === "native") { + return undefined; + } + + if (elicitationMode.mode === "model") { + return server.registerTool( + "resume", + { + description: [ + "Resume a paused execution using the executionId returned by execute.", + "This connection explicitly allows model-side resume via elicitation_mode=model.", + ].join("\n"), + inputSchema: { + executionId: z.string().describe("The execution ID from the paused result"), + action: z + .enum(["accept", "decline", "cancel"]) + .describe("How to respond to the interaction"), + content: z + .string() + .describe("Optional JSON-encoded response content for form elicitations") + .default("{}"), + persist: z + .string() + .optional() + .describe( + "How long an accepted approval lasts, when the paused interaction's terms offer a choice: one of interaction.meta.persist. Omit to approve this call only.", + ), + }, + }, + ({ executionId, action, content: rawContent, persist }, extra) => + runToolEffect( + resumeExecution( + executionId, + { + action, + content: parseJsonContent(rawContent), + ...(persist === undefined ? {} : { meta: { persist } }), + }, + extra, + ), + extra, + ), + ); + } - if (elicitationMode.mode === "model") { return server.registerTool( "resume", { description: [ - "Resume a paused execution using the executionId returned by execute.", - "This connection explicitly allows model-side resume via elicitation_mode=model.", + "Request user approval to resume a paused execution.", + "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.", + "This connection does not allow the model to choose accept, decline, cancel, or content.", ].join("\n"), inputSchema: { executionId: z.string().describe("The execution ID from the paused result"), - action: z - .enum(["accept", "decline", "cancel"]) - .describe("How to respond to the interaction"), - content: z - .string() - .describe("Optional JSON-encoded response content for form elicitations") - .default("{}"), }, }, - ({ executionId, action, content: rawContent }, extra) => - runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), - extra, - ), + ({ executionId }, extra) => + runToolEffect(resumeAfterBrowserApproval(executionId, extra), extra), ); - } - - return server.registerTool( - "resume", - { - description: [ - "Request user approval to resume a paused execution.", - "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.", - "This connection does not allow the model to choose accept, decline, cancel, or content.", - ].join("\n"), - inputSchema: { - executionId: z.string().describe("The execution ID from the paused result"), - }, - }, - ({ executionId }, extra) => - runToolEffect(resumeAfterBrowserApproval(executionId, extra), extra), + }).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "resume" }, + }), ); - }).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "resume" }, - }), - ); // --- per-integration search tools (opt-in, `?search_tools=true`) --- // @@ -1659,7 +2179,7 @@ export const createExecutorMcpServer = ( // would only repeat the name) and a single bare `query` parameter — no // paging knobs, because anything past the first page belongs in `execute`. // `namespace-search-tools.test.ts` pins the serialized size. - if (searchToolsEnabled) { + if (searchToolsEnabled && !passthrough) { // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already // conform (they are `tools.` property names in sandbox code); one // that somehow doesn't is skipped rather than failing the whole session. @@ -2042,11 +2562,14 @@ export const createExecutorMcpServer = ( .pipe(Effect.catchCause(() => Effect.succeed(null))); if (!artifact) return artifactNotFoundResult(id); yield* notifyArtifactUsage("viewed"); - return deliverArtifact({ - code: artifact.code, - artifactId: artifact.id, - title: artifact.title, - }); + return withArtifactSource( + deliverArtifact({ + code: artifact.code, + artifactId: artifact.id, + title: artifact.title, + }), + artifact.code, + ); }).pipe( Effect.withSpan("mcp.host.tool.show_artifact", { attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id }, @@ -2245,7 +2768,7 @@ export const createExecutorMcpServer = ( description: [ "Re-render a saved UI artifact by id.", "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.", - "Clients that cannot display MCP apps receive a link to the artifact instead.", + "Returns the artifact's current source. Clients that cannot display MCP apps also receive a link to the artifact; pass it to the user.", ].join("\n"), inputSchema: { id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."), @@ -2309,7 +2832,11 @@ export const createExecutorMcpServer = ( }, ({ executionId, action, content: rawContent }, extra) => runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + resumeExecution( + executionId, + { action, content: parseJsonContent(rawContent) }, + extra, + ), extra, ), ); diff --git a/packages/kernel/core/src/types.ts b/packages/kernel/core/src/types.ts index 1480b27b9a..d6dee10ad4 100644 --- a/packages/kernel/core/src/types.ts +++ b/packages/kernel/core/src/types.ts @@ -46,6 +46,8 @@ export type ExecuteResult = { /** Enumerable failure class for telemetry; never carries message content. */ errorKind?: ExecuteErrorKind; logs?: string[]; + /** Successful connected-tool paths observed during this execution. */ + toolPaths?: readonly string[]; }; /** diff --git a/packages/plugins/mcp/src/sdk/appserver-connector.test.ts b/packages/plugins/mcp/src/sdk/appserver-connector.test.ts index 4ef206064e..cd45e820bc 100644 --- a/packages/plugins/mcp/src/sdk/appserver-connector.test.ts +++ b/packages/plugins/mcp/src/sdk/appserver-connector.test.ts @@ -235,6 +235,39 @@ describe("codex app-server bridge", () => { ), ); + it.effect("carries the answer's persistence down to the app-server", () => + Effect.scoped( + Effect.gen(function* () { + // Computer Use's app approval OFFERS `persist: ["session", "always"]` + // and remembers the app only when the answer's `_meta.persist` names + // one. A reply rebuilt from `action` and `content` alone was a + // one-time approval, so the same app prompted on every call. + const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" })); + let offered: unknown; + connection.client.setRequestHandler("elicitation/create", (request) => { + offered = request.params._meta?.["persist"]; + return Promise.resolve({ + action: "accept" as const, + content: {}, + _meta: { persist: "always" }, + }); + }); + + const result = yield* Effect.promise(() => + connection.client.callTool({ + name: "get_app_state", + arguments: { app: "__needs_app_approval" }, + }), + ); + expect(offered, "the offered scopes reach the client").toEqual(["session", "always"]); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent, "and the chosen one reaches Codex").toEqual({ + persist: "always", + }); + }), + ), + ); + it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/plugins/mcp/src/sdk/appserver-connector.ts b/packages/plugins/mcp/src/sdk/appserver-connector.ts index 0f2703c44c..a4e6b47c16 100644 --- a/packages/plugins/mcp/src/sdk/appserver-connector.ts +++ b/packages/plugins/mcp/src/sdk/appserver-connector.ts @@ -159,6 +159,7 @@ const decodeElicitResult = Schema.decodeUnknownOption( Schema.Struct({ action: Schema.Literals(["accept", "decline", "cancel"]), content: Schema.optional(Schema.Unknown), + _meta: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))), }), ); @@ -708,12 +709,19 @@ class AppServerClientTransport implements Transport { const decoded = "result" in message ? Option.getOrUndefined(decodeElicitResult(message.result)) : undefined; // An error or unreadable answer cancels: never fabricate an approval. + // + // The answer's `_meta` goes down with it: that is where Codex reads the + // terms of an accept. Computer Use's app approval is the case — its + // request OFFERS `persist: ["session", "always"]`, and only an answer + // that names one is remembered. Dropping it here turned every accept + // into a one-time approval, so the same app prompted on every call. const result = decoded === undefined ? { action: "cancel" } : { action: decoded.action, ...(decoded.content === undefined ? {} : { content: decoded.content }), + ...(decoded._meta == null ? {} : { _meta: decoded._meta }), }; this.#sendDownstream({ jsonrpc: "2.0", id: downstreamId, result }); } diff --git a/packages/plugins/mcp/src/sdk/appserver-test-server.ts b/packages/plugins/mcp/src/sdk/appserver-test-server.ts index fc106f0595..487a469bb7 100644 --- a/packages/plugins/mcp/src/sdk/appserver-test-server.ts +++ b/packages/plugins/mcp/src/sdk/appserver-test-server.ts @@ -9,7 +9,11 @@ // server, so the bridge must follow `nextCursor`; // - a `needs_approval` tool that emits a server→client // `mcpServer/elicitation/request` and only succeeds when the answer is -// an accept — the round trip through executor's elicitation bridge. +// an accept — the round trip through executor's elicitation bridge; +// - approvals whose terms travel in `_meta`, in both directions: Chrome's +// per-site grant STATES `persist: "always"`, Computer Use's app approval +// OFFERS `persist: ["session", "always"]` and reads the answer's +// `_meta.persist` to know whether to remember the app. import * as readline from "node:readline"; import { Option, Schema } from "effect"; @@ -51,7 +55,11 @@ const decodeToolCallParams = Schema.decodeUnknownOption( ); const decodeElicitAnswer = Schema.decodeUnknownOption( - Schema.Struct({ action: Schema.String, content: Schema.optional(Schema.Unknown) }), + Schema.Struct({ + action: Schema.String, + content: Schema.optional(Schema.Unknown), + _meta: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))), + }), ); const THREAD_ID = "thread-fixture-1"; @@ -188,6 +196,34 @@ const handleToolCall = (id: number | string, params: unknown): void => { }); return; } + // A Computer-Use-shaped app approval: no schema to fill in, and the + // terms OFFER how long an accept lasts. The answer's `_meta.persist` + // picks one; without it the runtime treats the accept as one-time. + if (args?.code?.includes("__needs_app_approval")) { + const elicitationId = nextServerRequestId++; + pendingApprovals.set(elicitationId, id); + write({ + jsonrpc: "2.0", + id: elicitationId, + method: "mcpServer/elicitation/request", + params: { + threadId: THREAD_ID, + turnId: null, + serverName: "node_repl", + mode: "form", + message: 'Allow Computer Use to use "Finder"?', + requestedSchema: { type: "object", properties: {} }, + _meta: { + codex_approval_kind: "mcp_tool_call", + connector_id: "computer-use", + connector_name: "Computer Use", + persist: ["session", "always"], + riskLevel: "low", + }, + }, + }); + return; + } reply(id, { content: [{ type: "text", text: args?.code ?? "" }], // Echoed so a test can assert the turn metadata the Chrome client @@ -275,7 +311,11 @@ const handleElicitationAnswer = (id: number | string, result: unknown): void => pendingApprovals.delete(id); const answer = Option.getOrUndefined(decodeElicitAnswer(result)); if (answer?.action === "accept") { - reply(callId, { content: [{ type: "text", text: "approved" }] }); + reply(callId, { + content: [{ type: "text", text: "approved" }], + // Echoed so a test can assert what the runtime would remember. + structuredContent: { persist: answer._meta?.["persist"] ?? null }, + }); return; } reply(callId, { diff --git a/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts b/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts index db615ed2d4..80bd335f72 100644 --- a/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts +++ b/packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts @@ -75,8 +75,18 @@ describe("approval terms", () => { ).toEqual({ meta: { persist: "always" } }); }); - it("ignores non-string values and contributes nothing when no term applies", () => { + it("keeps the scopes an upstream OFFERS, not just the one it states", () => { + // Computer Use leaves the lifetime of an accept to the answer. Without + // the list, the approver cannot know a bare accept is one-time, nor + // which scopes it may answer with. + expect(approvalTerms({ persist: ["session", "always"], connector_id: "computer-use" })).toEqual( + { meta: { persist: ["session", "always"], connector_id: "computer-use" } }, + ); + }); + + it("ignores non-term values and contributes nothing when no term applies", () => { expect(approvalTerms({ persist: { always: true }, origin: 42 })).toEqual({}); + expect(approvalTerms({ persist: ["session", 7] })).toEqual({}); expect(approvalTerms({ progressToken: "tok" })).toEqual({}); expect(approvalTerms(undefined)).toEqual({}); }); diff --git a/packages/plugins/mcp/src/sdk/discover-close.test.ts b/packages/plugins/mcp/src/sdk/discover-close.test.ts new file mode 100644 index 0000000000..89f8e78e37 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/discover-close.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { createMcpConnector, type McpConnector } from "./connection"; +import { discoverTools } from "./discover"; +import { makeEchoMcpServer, serveMcpServer } from "../testing"; + +// Exercise the real MCP handshake and catalog. The connector owns teardown, +// so a wrapper can reproduce a transport that closes its sockets but never +// settles its close promise without replacing the protocol client. +const hangingCloseConnector = (connector: McpConnector, state: { closes: number }): McpConnector => + Effect.map(connector, (connection) => ({ + client: connection.client, + close: async () => { + state.closes += 1; + await connection.close(); + return new Promise(() => {}); + }, + })); + +describe("MCP discovery teardown", () => { + it.live("preserves a real catalog when close never settles", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer({ name: "hanging-close" })); + const state = { closes: 0 }; + const manifest = yield* discoverTools( + hangingCloseConnector( + createMcpConnector({ + transport: "remote", + endpoint: server.url, + remoteTransport: "streamable-http", + }), + state, + ), + ); + expect(state.closes).toBe(1); + expect(manifest.server?.name).toBe("hanging-close"); + expect(manifest.tools.length).toBeGreaterThan(0); + }), + ), + ); + + it.live("preserves listing failure when close never settles", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + yield* server.rejectSessionMethod("tools/list", 403); + const state = { closes: 0 }; + const result = yield* discoverTools( + hangingCloseConnector( + createMcpConnector({ + transport: "remote", + endpoint: server.url, + remoteTransport: "streamable-http", + }), + state, + ), + ).pipe(Effect.result); + expect(state.closes).toBe(1); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { stage: "list_tools", httpStatus: 403 }, + }); + }), + ), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/discover.ts b/packages/plugins/mcp/src/sdk/discover.ts index 754333eb35..d3c672361d 100644 --- a/packages/plugins/mcp/src/sdk/discover.ts +++ b/packages/plugins/mcp/src/sdk/discover.ts @@ -31,6 +31,12 @@ const MAX_LIST_TOOLS_PAGES = 100; // shape probe's single unauth POST. const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); +// Teardown is best-effort and paid for by the request that performed discovery. +// A remote transport may accept close and then never settle, so use the same +// bound as the invocation connection pool instead of stranding the caller in an +// uninterruptible finalizer after discovery itself has already completed. +const CLOSE_TIMEOUT = Duration.seconds(2); + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -243,13 +249,11 @@ export const discoverTools = ( const closeConnection = (connection: { readonly close: () => Promise; }): Effect.Effect => - Effect.ignore( - Effect.tryPromise({ - try: () => connection.close(), - catch: () => - new McpToolDiscoveryError({ - stage: "list_tools", - message: "Failed closing MCP connection", - }), - }), - ); + Effect.tryPromise({ + try: () => connection.close(), + catch: () => + new McpToolDiscoveryError({ + stage: "list_tools", + message: "Failed closing MCP connection", + }), + }).pipe(Effect.timeout(CLOSE_TIMEOUT), Effect.ignore); diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index cc2bed354f..69f108d008 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -167,6 +167,50 @@ describe("MCP elicitation (end-to-end)", () => { }), ); + it.effect("the answer's terms reach the server, and the offered ones reach the handler", () => + Effect.gen(function* () { + const server = yield* serveElicitationTestServer; + const executor = yield* makeTestExecutor(server.url); + const tools = yield* executor.tools.list(); + const rememberedEcho = findTool(tools, "remembered_echo"); + + let offered: unknown; + const remembered = yield* executor.execute( + rememberedEcho.address, + { value: "keep" }, + { + onElicitation: (ctx) => { + offered = ctx.request.meta; + return Effect.succeed( + ElicitationResponse.make({ + action: "accept", + content: {}, + meta: { persist: "always" }, + }), + ); + }, + }, + ); + const once = yield* executor.execute( + rememberedEcho.address, + { value: "drop" }, + { onElicitation: () => Effect.succeed(ElicitationResponse.make({ action: "accept" })) }, + ); + yield* executor.close(); + + expect(offered).toEqual({ persist: ["session", "always"] }); + expect(remembered).toMatchObject({ + ok: true, + data: { content: [{ type: "text", text: "approved:keep:always" }] }, + }); + // No choice made, none invented: a bare accept stays one-time. + expect(once).toMatchObject({ + ok: true, + data: { content: [{ type: "text", text: "approved:drop:once" }] }, + }); + }), + ); + it.effect("tool without elicitation works normally", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; diff --git a/packages/plugins/mcp/src/sdk/invoke.test.ts b/packages/plugins/mcp/src/sdk/invoke.test.ts index ee9100fb6b..2585776d2e 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -1,12 +1,15 @@ import { beforeAll, describe, expect, it } from "@effect/vitest"; import { Effect, Predicate } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; +// oxlint-disable-next-line executor/no-vitest-import -- boundary: fake-clock coverage for the active-work deadline +import { afterEach, vi } from "vitest"; import { ProtocolError, SdkErrorCode, SdkHttpError, type OAuthClientProvider, + type ClientContext, } from "@modelcontextprotocol/client"; import { ElicitationResponse } from "@executor-js/sdk"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; @@ -19,7 +22,7 @@ import { createMcpConnector, type McpConnection, type McpConnector } from "./con // that precondition here — these tests construct SDK errors directly. beforeAll(() => loadMcpClientSdk()); import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors"; -import { invokeMcpTool } from "./invoke"; +import { invokeMcpTool, makeActiveWorkDeadline, MCP_ACTIVE_WORK_TIMEOUT_MS } from "./invoke"; const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); @@ -148,6 +151,145 @@ const invocationRejectionCases = [ ]; describe("invokeMcpTool", () => { + afterEach(() => vi.useRealTimers()); + + it("pauses the active-work deadline across overlapping elicitations", () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + const deadline = makeActiveWorkDeadline(100); + + vi.advanceTimersByTime(40); + deadline.pause(); + deadline.pause(); + vi.advanceTimersByTime(1_000); + expect(deadline.signal.aborted).toBe(false); + + deadline.resume(); + vi.advanceTimersByTime(100); + expect(deadline.signal.aborted).toBe(false); + + deadline.resume(); + vi.advanceTimersByTime(59); + expect(deadline.signal.aborted).toBe(false); + vi.advanceTimersByTime(1); + expect(deadline.signal.aborted).toBe(true); + deadline.dispose(); + }); + + it("uses the active signal for a tool call and excludes elicitation from its deadline", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + + let requestHandler: + | ((request: { params: unknown }, context: ClientContext) => Promise) + | undefined; + let callOptions: { signal: AbortSignal; timeout: number } | undefined; + let finishElicitation: (() => void) | undefined; + let resolveElicitationStarted: (() => void) | undefined; + const elicitationStarted = new Promise((resolve) => { + resolveElicitationStarted = resolve; + }); + const connectionAbort = new AbortController(); + + const client = { + setRequestHandler: (_method: string, handler: unknown) => { + requestHandler = handler as typeof requestHandler; + }, + callTool: async (_request: unknown, options: { signal: AbortSignal; timeout: number }) => { + callOptions = options; + await requestHandler!( + { + params: { mode: "form", message: "Approve?", requestedSchema: {} }, + }, + { mcpReq: { signal: connectionAbort.signal } } as ClientContext, + ); + // oxlint-disable-next-line executor/no-promise-reject -- boundary: fake MCP client models SDK abort rejection + return await new Promise((_resolve, reject) => { + // oxlint-disable-next-line executor/no-promise-reject -- boundary: fake MCP client models SDK abort rejection + options.signal.addEventListener("abort", () => reject(options.signal.reason), { + once: true, + }); + }); + }, + }; + + const invocation = Effect.runPromise( + invokeMcpTool({ + toolId: "slow", + toolName: "slow", + args: {}, + transport: "streamable-http", + connector: Effect.succeed({ + // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only invokeMcpTool's surface + client: client as unknown as McpConnection["client"], + close: () => Promise.resolve(), + }), + elicit: () => + Effect.callback((resume) => { + resolveElicitationStarted!(); + finishElicitation = () => + resume(Effect.succeed(ElicitationResponse.make({ action: "accept" }))); + }), + }), + ).then( + () => "completed" as const, + () => "failed" as const, + ); + + await elicitationStarted; + expect(callOptions?.timeout).toBeGreaterThan(MCP_ACTIVE_WORK_TIMEOUT_MS); + vi.advanceTimersByTime(MCP_ACTIVE_WORK_TIMEOUT_MS); + expect(callOptions?.signal.aborted).toBe(false); + + finishElicitation!(); + await Promise.resolve(); + await Promise.resolve(); + vi.advanceTimersByTime(MCP_ACTIVE_WORK_TIMEOUT_MS); + expect(callOptions?.signal.aborted).toBe(true); + expect(await invocation).toBe("failed"); + }); + + it("interrupts an elicitation when the MCP connection closes", async () => { + let requestHandler: + | ((request: { params: unknown }, context: ClientContext) => Promise) + | undefined; + const connectionAbort = new AbortController(); + const client = { + setRequestHandler: (_method: string, handler: unknown) => { + requestHandler = handler as typeof requestHandler; + }, + callTool: async () => { + await requestHandler!( + { + params: { mode: "form", message: "Approve?", requestedSchema: {} }, + }, + { mcpReq: { signal: connectionAbort.signal } } as ClientContext, + ); + return { content: [] }; + }, + }; + + const invocation = Effect.runPromise( + invokeMcpTool({ + toolId: "closed", + toolName: "closed", + args: {}, + transport: "streamable-http", + connector: Effect.succeed({ + // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only invokeMcpTool's surface + client: client as unknown as McpConnection["client"], + close: () => Promise.resolve(), + }), + elicit: () => Effect.callback(() => undefined), + }), + ).then( + () => "completed" as const, + () => "failed" as const, + ); + + await Promise.resolve(); + connectionAbort.abort(); + expect(await invocation).toBe("failed"); + }); + for (const testCase of invocationRejectionCases) { it.effect(testCase.name, () => Effect.gen(function* () { diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 4b7a433c7c..af5257d122 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -16,7 +16,7 @@ import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; -import type { ProtocolError } from "@modelcontextprotocol/client"; +import type { ClientContext, ProtocolError } from "@modelcontextprotocol/client"; // SDK error classes come through the lazy loader; by the time a tool call can // fail, the connect path has always loaded the module (see client-module.ts). @@ -39,6 +39,95 @@ import { httpStatusFromCause, insufficientScopeFromCause } from "./http-status"; // Helpers // --------------------------------------------------------------------------- +/** + * The MCP SDK's default request timer measures wall-clock time. An elicitation + * is user work, so it must not consume the tool's active-work budget. The SDK + * still gets a long timer as a transport-level backstop; this controller owns + * the normal deadline and is paused while one or more elicitation handlers are + * waiting for input. + */ +export const MCP_ACTIVE_WORK_TIMEOUT_MS = 60_000; +const MCP_SDK_TIMEOUT_BACKSTOP_MS = 2_147_483_647; + +export type ActiveWorkDeadline = { + readonly signal: AbortSignal; + readonly pause: () => void; + readonly resume: () => void; + readonly dispose: () => void; +}; + +export const makeActiveWorkDeadline = ( + timeoutMs: number = MCP_ACTIVE_WORK_TIMEOUT_MS, +): ActiveWorkDeadline => { + const controller = new AbortController(); + let remainingMs = timeoutMs; + let pendingElicitations = 0; + let startedAt: number | undefined; + let timer: ReturnType | undefined; + + const stopTimer = (): void => { + if (timer === undefined || startedAt === undefined) return; + clearTimeout(timer); + timer = undefined; + remainingMs = Math.max(0, remainingMs - (Date.now() - startedAt)); + startedAt = undefined; + }; + + const abortForTimeout = (): void => { + timer = undefined; + startedAt = undefined; + // oxlint-disable-next-line executor/no-error-constructor -- boundary: AbortSignal consumers need a stable timeout reason + controller.abort(new Error("MCP tool invocation exceeded its active-work deadline")); + }; + + const startTimer = (): void => { + if (controller.signal.aborted || pendingElicitations > 0) return; + if (remainingMs <= 0) { + abortForTimeout(); + return; + } + startedAt = Date.now(); + timer = setTimeout(() => { + remainingMs = 0; + abortForTimeout(); + }, remainingMs); + }; + + startTimer(); + + return { + signal: controller.signal, + pause: () => { + pendingElicitations += 1; + if (pendingElicitations === 1) stopTimer(); + }, + resume: () => { + if (pendingElicitations === 0) return; + pendingElicitations -= 1; + if (pendingElicitations === 0) startTimer(); + }, + dispose: () => { + stopTimer(); + // oxlint-disable-next-line executor/no-error-constructor -- boundary: disposing the scoped signal must interrupt SDK work + controller.abort(new Error("MCP tool invocation was disposed")); + }, + }; +}; + +const abortOnSignals = (signals: readonly AbortSignal[]): Effect.Effect => + Effect.callback((resume) => { + // oxlint-disable-next-line executor/no-error-constructor -- boundary: an aborted MCP handler must reject its JSON-RPC response + const abort = () => resume(Effect.fail(new Error("MCP elicitation was cancelled"))); + if (signals.some((signal) => signal.aborted)) { + abort(); + return; + } + for (const signal of signals) signal.addEventListener("abort", abort, { once: true }); + return Effect.sync(() => { + for (const signal of signals) signal.removeEventListener("abort", abort); + }); + }); + const ArgsRecord = Schema.Record(Schema.String, Schema.Unknown); const decodeArgsRecord = Schema.decodeUnknownOption(ArgsRecord); @@ -129,12 +218,21 @@ const decodeElicitContent = Schema.decodeUnknownSync( * server contributes nothing rather than noise. */ export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const; +const isStringList = (value: unknown): value is readonly string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"); + +/** A term is a string, or a list of strings: Computer Use OFFERS + * `persist: ["session", "always"]` for the answer to pick from, where + * Chrome STATES `persist: "always"`. Either way it is a term of the grant. */ +const isApprovalTerm = (value: unknown): value is string | readonly string[] => + typeof value === "string" || isStringList(value); + export const approvalTerms = (meta: Record | undefined) => { if (meta === undefined) return {}; const terms = Object.fromEntries( APPROVAL_TERM_KEYS.flatMap((key) => { const value = meta[key]; - return typeof value === "string" ? [[key, value] as const] : []; + return isApprovalTerm(value) ? [[key, value] as const] : []; }), ); return Object.keys(terms).length > 0 ? { meta: terms } : {}; @@ -156,36 +254,55 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => { }); }; -const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => { - client.setRequestHandler("elicitation/create", async (request: { params: unknown }) => { - const params = decodeElicitParams(request.params); - const req = toElicitationRequest(params); - // Use runPromiseExit so we can inspect typed failures — `elicit` - // fails with `ElicitationDeclinedError` on decline/cancel, which - // we translate into the equivalent MCP elicit response instead of - // surfacing as a JSON-RPC error. - const exit = await Effect.runPromiseExit(elicit(req)); - if (Exit.isSuccess(exit)) { - const response = exit.value; - return { - action: response.action, - ...(response.action === "accept" && response.content - ? { content: decodeElicitContent(response.content) } - : {}), - }; - } - const failure = exit.cause.reasons.find(Cause.isFailReason); - if (failure) { - const err = failure.error; - if (Predicate.isTagged(err, "ElicitationDeclinedError")) { - const action = - Predicate.hasProperty(err, "action") && err.action === "cancel" ? "cancel" : "decline"; - return { action }; +const installElicitationHandler = ( + client: McpConnection["client"], + elicit: Elicit, + deadline: ActiveWorkDeadline, +): void => { + client.setRequestHandler( + "elicitation/create", + async (request: { params: unknown }, ctx: ClientContext) => { + const params = decodeElicitParams(request.params); + const req = toElicitationRequest(params); + deadline.pause(); + // Use runPromiseExit so we can inspect typed failures — `elicit` + // fails with `ElicitationDeclinedError` on decline/cancel, which + // we translate into the equivalent MCP elicit response instead of + // surfacing as a JSON-RPC error. + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK request handlers are promise callbacks and must release the active-work lease + try { + const exit = await Effect.runPromiseExit( + Effect.raceFirst(elicit(req), abortOnSignals([ctx.mcpReq.signal, deadline.signal])), + ); + if (Exit.isSuccess(exit)) { + const response = exit.value; + const persist = response.action === "accept" ? response.meta?.persist : undefined; + return { + action: response.action, + ...(response.action === "accept" && response.content + ? { content: decodeElicitContent(response.content) } + : {}), + ...(persist === undefined ? {} : { _meta: { persist } }), + }; + } + const failure = exit.cause.reasons.find(Cause.isFailReason); + if (failure) { + const err = failure.error; + if (Predicate.isTagged(err, "ElicitationDeclinedError")) { + const action = + Predicate.hasProperty(err, "action") && err.action === "cancel" + ? "cancel" + : "decline"; + return { action }; + } + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK async request handlers signal unexpected failures by rejecting + throw Cause.squash(exit.cause); + } finally { + deadline.resume(); } - } - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK async request handlers signal unexpected failures by rejecting - throw Cause.squash(exit.cause); - }); + }, + ); }; // --------------------------------------------------------------------------- @@ -218,10 +335,18 @@ const useConnection = ( onToolListChanged: (() => void) | undefined, ): Effect.Effect => Effect.gen(function* () { - installElicitationHandler(connection.client, elicit); + const deadline = yield* Effect.acquireRelease( + Effect.sync(() => makeActiveWorkDeadline()), + (activeWork) => Effect.sync(activeWork.dispose), + ); + installElicitationHandler(connection.client, elicit, deadline); installToolListChangedHandler(connection.client, onToolListChanged); return yield* Effect.tryPromise({ - try: () => connection.client.callTool({ name: toolName, arguments: args }), + try: () => + connection.client.callTool( + { name: toolName, arguments: args }, + { signal: deadline.signal, timeout: MCP_SDK_TIMEOUT_BACKSTOP_MS }, + ), catch: (cause) => { if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) { return new McpOAuthReauthorizationRequired({ @@ -258,7 +383,7 @@ const useConnection = ( attributes: { "mcp.tool.name": toolName }, }), ); - }); + }).pipe(Effect.scoped); // --------------------------------------------------------------------------- // Public API diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 7c47d3fbe2..2c0ca2f20c 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -36,6 +36,9 @@ export type McpTestRequest = { export type McpTestServerOptions = { readonly path?: string; + /** Hold authenticated requests at the transport boundary until the test + * releases them, so callback ordering does not depend on elapsed time. */ + readonly beforeAuthenticatedRequest?: () => Promise; readonly auth?: { readonly validateAuthorization: (authorization: string | undefined) => Effect.Effect; readonly authorizationServerUrls?: readonly string[]; @@ -173,6 +176,9 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO writeUnauthorized(response, origin); return; } + if (options.beforeAuthenticatedRequest !== undefined) { + yield* Effect.promise(options.beforeAuthenticatedRequest); + } } if (sessionId && request.method === "POST" && nextSessionRequestStatus !== undefined) { @@ -344,6 +350,7 @@ export const serveMcpServerWithOAuth = ( const oauth = yield* OAuthTestServer; return yield* serveMcpServer(factory, { path: options.path, + beforeAuthenticatedRequest: options.beforeAuthenticatedRequest, auth: { validateAuthorization: oauth.acceptsAuthorizationHeader, authorizationServerUrls: [oauth.issuerUrl], @@ -537,6 +544,37 @@ export const makeElicitationMcpServer = () => { }, ); + server.registerTool( + "remembered_echo", + { + description: "Asks for approval whose terms offer to remember it", + inputSchema: { value: z.string() }, + }, + async ({ value }: { value: string }) => { + // Shaped like Codex Computer Use's app approval: an empty schema, and + // the persistence scopes on offer in `_meta`. The answer's own + // `_meta.persist` is what the server would remember. + const response = await server.server.elicitInput({ + mode: "form", + message: `Allow the echo of "${value}"?`, + requestedSchema: { type: "object", properties: {} }, + _meta: { persist: ["session", "always"] }, + }); + if (response.action !== "accept") { + return { content: [{ type: "text" as const, text: `denied:${value}` }] }; + } + const persist = response._meta?.["persist"]; + return { + content: [ + { + type: "text" as const, + text: `approved:${value}:${typeof persist === "string" ? persist : "once"}`, + }, + ], + }; + }, + ); + server.registerTool( "simple_echo", { diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index 748f8bc917..a6a6f18702 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -159,6 +159,11 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest", ), ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1"); + expect( + normalizeGoogleDiscoveryUrl( + "https://www.googleapis.com/discovery/v1/apis/analyticsdata/v1beta/rest", + ), + ).toBe("https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta"); expect( normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/forms/v1/rest"), ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1"); @@ -171,6 +176,11 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { expect( normalizeGoogleDiscoveryUrl("https://photospicker.googleapis.com/$discovery/rest?version=v1"), ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1"); + expect( + normalizeGoogleDiscoveryUrl( + "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta", + ), + ).toBe("https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta"); expect( normalizeGoogleDiscoveryUrl("https://forms.googleapis.com/$discovery/rest?version=v1"), ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1"); diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 9d79076986..8d346561c3 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -41,6 +41,7 @@ type GoogleDiscoveryServiceOverride = { }; const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record = { + analyticsdata: { preserveServiceHostedUrl: true }, forms: { preserveServiceHostedUrl: true }, keep: { preserveServiceHostedUrl: true }, [GOOGLE_PHOTOS_PICKER_SERVICE]: { diff --git a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx index bb8e303a32..9fcb2de7fb 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx @@ -18,7 +18,11 @@ import { useIntegrationIdentity, } from "@executor-js/react/plugins/integration-identity"; import { Button } from "@executor-js/react/components/button"; -import { HealthCheckConfigFields } from "@executor-js/react/components/health-check-editor"; +import { + HealthCheckConfigFields, + isUnsupportedHealthCheck, + parseHealthCheckArgs, +} from "@executor-js/react/components/health-check-editor"; import { AuthMethodListEditor, useAuthMethodList, @@ -374,6 +378,7 @@ export default function AddOpenApiIntegration(props: { const hcMissingRequired = hcRequiredParams.some( (p) => (hcArgs[p.name] ?? "").trim().length === 0, ); + const hcParsedArgs = parseHealthCheckArgs(hcArgs); const onHcOperationChange = (next: string) => { setHcOperation(next); @@ -398,7 +403,10 @@ export default function AddOpenApiIntegration(props: { parsedSpecOverrides.ok && !slugAlreadyExists && (!previewHasNoServers || resolvedBaseUrl.length > 0) && - !(hcOperation.length > 0 && hcMissingRequired); + !( + hcOperation.length > 0 && + (hcMissingRequired || !hcParsedArgs.ok || isUnsupportedHealthCheck(hcSelected)) + ); // ---- Handlers ---- @@ -499,14 +507,11 @@ export default function AddOpenApiIntegration(props: { // the user (re-submitting the form hits the slug-already-exists guard). The // check stays editable from the integration's detail page, so on failure we // proceed to onComplete regardless and let the user fix it there. - if (hcOperation.length > 0) { + if (hcOperation.length > 0 && hcParsedArgs.ok) { const identity = hcIdentityField.trim(); - const argEntries = Object.entries(hcArgs) - .map(([key, value]) => [key, value.trim()] as const) - .filter(([, value]) => value.length > 0); const spec: HealthCheckSpec = { operation: hcOperation, - ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + ...(Object.keys(hcParsedArgs.args).length > 0 ? { args: hcParsedArgs.args } : {}), ...(identity.length > 0 ? { identityField: identity } : {}), }; // Best-effort: the exit is intentionally ignored so a save failure cannot diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index b7a54d4f43..82cd6dcdba 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -53,6 +53,7 @@ import { parse, type ParsedDocument } from "./parse"; import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; import { type OpenapiStore, type StoredOperation } from "./store"; import { OperationBinding } from "./types"; +import { getHealthCheckParameters } from "./health-check-operation"; const STRINGIFIED_BODY_CAP = 1024; const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); @@ -629,6 +630,22 @@ export const resolveOpenApiBackedTools = ({ }; }); +// Transport failures used to escape as defects, which the hosts log with a +// correlation id. As a typed tool failure nothing else records them, so log +// and annotate the span with the sanitized classification operators need to +// tell DNS from refused from TLS. +const recordUpstreamUnreachable = (integration: string, error: OpenApiInvocationError) => { + const annotations = { + "plugin.openapi.integration": integration, + "plugin.openapi.upstream.host": error.upstreamHost ?? "unknown", + "plugin.openapi.upstream.transport_code": error.transportCode ?? "unknown", + }; + return Effect.logWarning("OpenAPI upstream unreachable").pipe( + Effect.annotateLogs(annotations), + Effect.andThen(Effect.annotateCurrentSpan(annotations)), + ); +}; + export const invokeOpenApiBackedTool = (input: { readonly ctx: PluginCtx; readonly toolRow: { readonly integration: string; readonly name: string }; @@ -727,7 +744,28 @@ export const invokeOpenApiBackedTool = (input: { details: error.cause ?? error, }), }) - : Effect.fail(error), + : error.reason === "transport_error" + ? recordUpstreamUnreachable(integration, error).pipe( + Effect.as({ + ok: false as const, + failure: ToolResult.fail({ + code: "upstream_unreachable", + // Executor sends the request, not the user's browser, so + // point at what the user can act on: the configured + // origin and the service behind it. + message: `Could not reach the upstream server for "${integration}"${error.upstreamHost ? ` at ${error.upstreamHost}` : ""}. Verify the integration's base URL and that the service is online, then try again.`, + // Unlike the timeout branches, `error.cause` is withheld: + // the TransportError carries the whole request, including + // resolved auth headers. Absent fields are dropped, not + // `undefined`: the result must stay a JSON value. + details: { + ...(error.upstreamHost !== undefined ? { host: error.upstreamHost } : {}), + ...(error.transportCode !== undefined ? { code: error.transportCode } : {}), + }, + }), + }), + ) + : Effect.fail(error), ), ); @@ -905,16 +943,15 @@ export const checkHealthOpenApi = (input: { } satisfies HealthCheckResult; } - // HARD block, not just a ranking hint: a health check runs unattended and - // repeatedly, so a mutating operation must never execute through it. The - // normal tool path gates these behind approval, and this path has no - // approval step. The candidate list labels these "(writes)"; refusing here - // is the enforcement. - if (REQUIRE_APPROVAL.has(binding.method.toLowerCase())) { + // HTTP RPC reads can use POST; the editor warns users before enabling them. + if ( + REQUIRE_APPROVAL.has(binding.method.toLowerCase()) && + binding.method.toLowerCase() !== "post" + ) { return { status: "unknown", checkedAt, - detail: `Health check operation "${spec.operation}" is a ${binding.method.toUpperCase()} (mutating): pick a read-only operation.`, + detail: `Health check operation "${spec.operation}" uses ${binding.method.toUpperCase()} and is not supported for health checks. Pick a read-only operation.`, } satisfies HealthCheckResult; } @@ -1068,19 +1105,12 @@ export const listHealthCheckCandidatesOpenApi = (input: { const candidates = operations.map((op): HealthCheckCandidate => { const method = op.binding.method.toLowerCase(); - const parameters = op.binding.parameters.map((parameter) => ({ - name: parameter.name, - location: parameter.location, - required: parameter.required, - ...(Option.isSome(parameter.description) - ? { description: parameter.description.value } - : {}), - })); + const parameters = getHealthCheckParameters(op.binding); const responseFields = responseFieldsByTool.get(op.toolName); return { operation: op.toolName, method, - requiredArgCount: op.binding.parameters.filter((parameter) => parameter.required).length, + requiredArgCount: parameters.filter((parameter) => parameter.required).length, destructive: REQUIRE_APPROVAL.has(method), summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`, ...(parameters.length > 0 ? { parameters } : {}), diff --git a/packages/plugins/openapi/src/sdk/errors.ts b/packages/plugins/openapi/src/sdk/errors.ts index 6a5fc4a8cc..a13a2aa29e 100644 --- a/packages/plugins/openapi/src/sdk/errors.ts +++ b/packages/plugins/openapi/src/sdk/errors.ts @@ -39,7 +39,19 @@ export class OpenApiSpecOverrideError extends Schema.TaggedErrorClass; - readonly reason?: "response_headers_timeout" | "response_body_timeout" | "unknown_arguments"; + readonly reason?: + | "response_headers_timeout" + | "response_body_timeout" + | "unknown_arguments" + | "transport_error"; + // `host[:port]` of a request that failed at the transport layer. It is the + // integration's configured origin, so it is safe to show; the path, query, + // and headers stay on `cause`. + readonly upstreamHost?: string | undefined; + // Errno-style code behind a transport failure (`ECONNREFUSED`, `ENOTFOUND`, + // `UND_ERR_SOCKET`, …) when the runtime exposes one. Tells DNS from refused + // from TLS without exposing the request. + readonly transportCode?: string | undefined; readonly cause?: unknown; }> {} diff --git a/packages/plugins/openapi/src/sdk/extract.ts b/packages/plugins/openapi/src/sdk/extract.ts index c242160a18..c11c5e0f99 100644 --- a/packages/plugins/openapi/src/sdk/extract.ts +++ b/packages/plugins/openapi/src/sdk/extract.ts @@ -976,6 +976,7 @@ export interface StreamedPreviewParameter { * add screen's operation list and health-check candidate ranking need, and * nothing that scales with schema size. */ export interface StreamedPreviewOperation { + readonly requestBodyRequired?: boolean; readonly operationId: string; /** Tool path planned over the full kept operation set, so preview candidates * match the names registration will assign. */ @@ -1050,6 +1051,7 @@ export const streamPreviewOperations = ( }), ); metas.push({ + requestBodyRequired: extractRequestBody(operation, r)?.required, operationId, method, pathTemplate: resolvedPathTemplate, diff --git a/packages/plugins/openapi/src/sdk/health-check-operation.test.ts b/packages/plugins/openapi/src/sdk/health-check-operation.test.ts new file mode 100644 index 0000000000..b478da2554 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/health-check-operation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { dump } from "js-yaml"; + +import { previewSpecText, previewSpecTextStreaming } from "./preview"; + +const readOperation = { + operationId: "getAccount", + parameters: [ + { + name: "RPC-Version", + in: "header", + required: true, + schema: { type: "string" }, + }, + ], + responses: { "200": { description: "OK" } }, +}; + +describe("health-check operations", () => { + it.effect("keeps body requirements and POST risk visible in both preview paths", () => + Effect.gen(function* () { + for (const required of [true, false]) { + const specText = dump({ + openapi: "3.0.3", + info: { title: "Account RPC API", version: "1" }, + servers: [{ url: "https://api.example.test" }], + paths: { + "/rpc/getAccount": { + post: { + ...readOperation, + requestBody: { + required, + content: { "application/json": { schema: { type: "object" } } }, + }, + }, + }, + "/me": { + get: { + operationId: "getMe", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + const whole = yield* previewSpecText(specText); + const streamed = yield* previewSpecTextStreaming(specText); + expect(streamed.healthCheckCandidates).toEqual(whole.healthCheckCandidates); + expect(whole.healthCheckCandidates[0]?.method).toBe("get"); + expect( + whole.healthCheckCandidates.find((candidate) => candidate.method === "post"), + ).toMatchObject({ + destructive: true, + requiredArgCount: required ? 2 : 1, + parameters: [ + { name: "RPC-Version", location: "header", required: true }, + { name: "body", location: "body", required }, + ], + }); + } + }), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/health-check-operation.ts b/packages/plugins/openapi/src/sdk/health-check-operation.ts new file mode 100644 index 0000000000..f238665858 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/health-check-operation.ts @@ -0,0 +1,18 @@ +import { Option } from "effect"; +import type { HealthCheckCandidateParameter } from "@executor-js/sdk/core"; + +import type { OperationBinding } from "./types"; + +export const getHealthCheckParameters = ( + operation: Pick, +): HealthCheckCandidateParameter[] => [ + ...operation.parameters.map((parameter) => ({ + name: parameter.name, + location: parameter.location, + required: parameter.required, + ...(Option.isSome(parameter.description) ? { description: parameter.description.value } : {}), + })), + ...(Option.isSome(operation.requestBody) + ? [{ name: "body", location: "body", required: operation.requestBody.value.required }] + : []), +]; diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 2f9bd0f6f5..8d180e6952 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -1,5 +1,10 @@ -import { Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { Effect, Exit, Fiber, Layer, Option, Predicate, Schema, Stream } from "effect"; +import { + HttpClient, + type HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { isToolFile, type ToolFileValue } from "@executor-js/sdk/core"; import { OpenApiInvocationError } from "./errors"; @@ -1148,6 +1153,44 @@ export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* ( return request; }); +// --------------------------------------------------------------------------- +// Transport failure classification +// --------------------------------------------------------------------------- + +const urlHost = Option.liftThrowable((url: string) => new URL(url).host); + +// `fetch` rejects with a generic `TypeError("fetch failed")`; the errno-style +// code (`ECONNREFUSED`, `ENOTFOUND`, `UND_ERR_SOCKET`, …) sits on the innermost +// link of its `cause` chain. The walk is bounded so a cyclic cause cannot spin. +const TransportCauseLink = Schema.Struct({ + code: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Unknown), +}); +const decodeTransportCauseLink = Schema.decodeUnknownOption(TransportCauseLink); +const TRANSPORT_CAUSE_MAX_DEPTH = 5; + +const transportFailureCode = (cause: unknown, depth = 0): string | undefined => + Option.match(decodeTransportCauseLink(cause), { + onNone: () => undefined, + onSome: (link) => + (link.cause !== undefined && depth < TRANSPORT_CAUSE_MAX_DEPTH + ? transportFailureCode(link.cause, depth + 1) + : undefined) ?? link.code, + }); + +// A transport failure produced no response: DNS, connection refused, TLS, or a +// socket dropped before headers. The TransportError carries the whole request +// (URL, headers, credentials), so only the origin and the errno-style code are +// lifted onto the invocation error. +const transportFailureFields = (reason: HttpClientError.HttpClientError["reason"]) => + Predicate.isTagged(reason, "TransportError") + ? { + reason: "transport_error" as const, + upstreamHost: Option.getOrUndefined(urlHost(reason.request.url)), + transportCode: transportFailureCode(reason.cause), + } + : {}; + // --------------------------------------------------------------------------- // Public API — invoke a single operation // --------------------------------------------------------------------------- @@ -1183,6 +1226,7 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( (err) => new OpenApiInvocationError({ message: "HTTP request failed", + ...transportFailureFields(err.reason), statusCode: Option.none(), cause: err, }), @@ -1197,7 +1241,6 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( }), ), ); - const fiber = runFork(responseEffect); const interrupt = () => { runFork(Fiber.interrupt(fiber)); }; @@ -1207,6 +1250,7 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( interrupt(); resume(Effect.succeed(Option.none())); }, responseHeadersTimeoutMs); + const fiber = runFork(responseEffect); signal.addEventListener("abort", interrupt, { once: true }); return Effect.sync(() => { clearTimeout(timer); @@ -1257,7 +1301,6 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( }), ), ); - const fiber = runFork(bodyEffect); const interrupt = () => { runFork(Fiber.interrupt(fiber)); }; @@ -1267,6 +1310,7 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( interrupt(); resume(Effect.succeed(Option.none())); }, responseBodyTimeoutMs); + const fiber = runFork(bodyEffect); signal.addEventListener("abort", interrupt, { once: true }); return Effect.sync(() => { clearTimeout(timer); diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 51279af8f8..84506ad475 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -25,10 +25,10 @@ import { type KeepPathItem, } from "./split"; import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types"; +import { getHealthCheckParameters } from "./health-check-operation"; -// Mutating HTTP methods: mirrors `REQUIRE_APPROVAL` in `./invoke` but kept -// inline so this browser-safe preview module never pulls in the HTTP execution -// path. A health check should be safe to re-run, so these rank last. +// Keep potentially mutating methods ranked below ordinary reads. This mirrors +// REQUIRE_APPROVAL without importing the HTTP execution path into the browser. const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]); // Cap on health-check candidate METADATA carried in the preview, so the add @@ -487,18 +487,11 @@ const buildPreviewHealthCheckCandidates = ( .map((def): HealthCheckCandidate => { const op = def.operation; const method = op.method.toLowerCase(); - const parameters = op.parameters.map((parameter) => ({ - name: parameter.name, - location: parameter.location, - required: parameter.required, - ...(Option.isSome(parameter.description) - ? { description: parameter.description.value } - : {}), - })); + const parameters = getHealthCheckParameters(op); return { operation: def.toolPath, method, - requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, + requiredArgCount: parameters.filter((parameter) => parameter.required).length, destructive: DESTRUCTIVE_METHODS.has(method), summary: Option.getOrUndefined(op.summary) ?? @@ -590,13 +583,19 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s const streamedCandidate = (op: StreamedPreviewOperation): HealthCheckCandidate => { const method = op.method.toLowerCase(); + const parameters = [ + ...op.parameters, + ...(op.requestBodyRequired === undefined + ? [] + : [{ name: "body", location: "body", required: op.requestBodyRequired }]), + ]; return { operation: op.toolPath, method, - requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, + requiredArgCount: parameters.filter((parameter) => parameter.required).length, destructive: DESTRUCTIVE_METHODS.has(method), summary: op.summary ?? op.description ?? `${method.toUpperCase()} ${op.pathTemplate}`, - ...(op.parameters.length > 0 ? { parameters: op.parameters } : {}), + ...(parameters.length > 0 ? { parameters } : {}), }; }; diff --git a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts index 2089e2fdf0..1f52b30c50 100644 --- a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts +++ b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts @@ -11,8 +11,14 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Schema } from "effect"; -import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { Cause, Data, Effect, Exit, Layer, Logger, References, Schema } from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientError, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; import { HttpApi, HttpApiBuilder, @@ -43,8 +49,10 @@ import { import { openApiPlugin } from "./plugin"; -const testPlugins = () => - [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; +class AdapterDefect extends Data.TaggedError("AdapterDefect") {} + +const testPlugins = (httpClientLayer = FetchHttpClient.layer) => + [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; // `/things` GET op `listThings` under group "things" → tool path // `things.listThings`, used verbatim (dots and all) as the address tool segment. @@ -102,6 +110,30 @@ const startDroppingServer = () => (s) => Effect.sync(() => s.close()), ); +// Bind an ephemeral port, then release it so nothing listens there and the +// kernel refuses the connection (`ECONNREFUSED`). Port 1 is not equivalent: +// `fetch` rejects it as a bad port before dialing, with no errno. +const refusedBaseUrl = () => + Effect.callback((resume) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + server.close(() => resume(Effect.succeed(`http://127.0.0.1:${port}`))); + }); + }); + +type CapturedLog = { readonly message: string; readonly annotations: Record }; + +const capturingLogger = (sink: Array) => + Logger.layer([ + Logger.make((options) => { + sink.push({ + message: String(options.message), + annotations: options.fiber.getRef(References.CurrentLogAnnotations), + }); + }), + ]); + const ThingsGroup = HttpApiGroup.make("things").add( HttpApiEndpoint.get("listThings", "/things", { success: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), @@ -114,9 +146,11 @@ const FailureApi = HttpApi.make("failuresTest") // Build an executor + connection from the FailureApi HttpApi against an // arbitrary baseUrl (used for the Node-transport socket-drop / slow cases). -const buildExecutor = (baseUrl: string) => +const buildExecutor = (baseUrl: string, httpClientLayer = FetchHttpClient.layer) => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const executor = yield* createExecutor( + makeTestConfig({ plugins: testPlugins(httpClientLayer) }), + ); yield* executor.openapi.addSpec( makeOpenApiHttpApiTestIntegrationConfig(FailureApi, { slug: "f", baseUrl }), ); @@ -394,9 +428,12 @@ describe("OpenAPI upstream failure modes", () => { const { baseUrl } = yield* startDroppingServer(); const { executor, address } = yield* buildExecutor(baseUrl); - const exit = yield* executor.execute(address, {}).pipe(Effect.exit); + const result = yield* executor.execute(address, {}); - expect(Exit.isFailure(exit)).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { code: "upstream_unreachable", details: { code: expect.any(String) } }, + }); }), ); @@ -446,4 +483,126 @@ describe("OpenAPI upstream failure modes", () => { expect(result.data).toEqual([]); }), ); + + it.effect("request encoding failures remain invocation failures", () => + Effect.gen(function* () { + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.EncodeError({ request, cause: new AdapterDefect() }), + }), + ), + ), + ); + const { executor, address } = yield* buildExecutor( + "https://upstream.example", + httpClientLayer, + ); + const exit = yield* executor.execute(address, {}).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); + + it.effect("transport defects remain defects", () => + Effect.gen(function* () { + const defect = new AdapterDefect(); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die(defect)), + ); + const { executor, address } = yield* buildExecutor( + "https://upstream.example", + httpClientLayer, + ); + const exit = yield* executor.execute(address, {}).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(Exit.match(exit, { onFailure: Cause.hasDies, onSuccess: () => false })).toBe(true); + }), + ); + + it.effect("interrupted transport remains interrupted", () => + Effect.gen(function* () { + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.interrupt), + ); + const { executor, address } = yield* buildExecutor( + "https://upstream.example", + httpClientLayer, + ); + const exit = yield* executor.execute(address, {}).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(Exit.match(exit, { onFailure: Cause.hasInterrupts, onSuccess: () => false })).toBe( + true, + ); + }), + ); + + // Port 1 refuses immediately. The same path used to throw `Internal tool + // error [hex]` because the raw HttpClientError carries the request URL. + // Executor makes the request, so the message names the integration and + // origin the user can fix instead of blaming their own network. + it.effect("connection refused names the integration and origin without the path", () => + Effect.gen(function* () { + const { executor, address } = yield* buildExecutor("http://127.0.0.1:1"); + + const result = yield* executor.execute(address, {}); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "upstream_unreachable", + message: expect.stringContaining( + 'Could not reach the upstream server for "f" at 127.0.0.1:1.', + ), + }, + }); + const failure = result as { + readonly ok: false; + readonly error: { readonly message: string; readonly details?: unknown }; + }; + // No errno here (`fetch` rejects port 1 before dialing). The result + // crosses a JSON boundary, so the missing code must be absent rather + // than an `undefined` property. + expect(failure.error.details).toStrictEqual({ host: "127.0.0.1:1" }); + expect(failure.error.message).toContain("base URL"); + expect(failure.error.message).not.toContain("your network"); + expect(failure.error.message).not.toContain("Internal tool error"); + expect(failure.error.message).not.toContain("/things"); + }), + ); + + // Classifying the failure took it off the hosts' correlation-id defect log, + // so the sanitized cause must reach both the caller (details) and operators + // (log) — and never the request, which carries the resolved auth header. + it.effect("connection refused reports the errno code to the caller and the log", () => + Effect.gen(function* () { + const baseUrl = yield* refusedBaseUrl(); + const { executor, address } = yield* buildExecutor(baseUrl); + const logged: Array = []; + + const result = yield* executor + .execute(address, {}) + .pipe(Effect.provide(capturingLogger(logged))); + + const host = new URL(baseUrl).host; + expect(result).toMatchObject({ + ok: false, + error: { code: "upstream_unreachable", details: { host, code: "ECONNREFUSED" } }, + }); + const rendered = JSON.stringify(result); + expect(rendered).not.toContain("/things"); + // The apiKey value `buildExecutor` puts on the connection. + expect(rendered).not.toContain("token"); + + const warning = logged.find((entry) => entry.message.includes("upstream unreachable")); + expect(warning?.annotations).toMatchObject({ + "plugin.openapi.integration": "f", + "plugin.openapi.upstream.host": host, + "plugin.openapi.upstream.transport_code": "ECONNREFUSED", + }); + }), + ); }); diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 465e7b88c3..1161bbb5fc 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -169,6 +169,7 @@ export interface AnalyticsEvents { mcp_install_elicitation_mode_changed: { elicitation_mode: string }; mcp_install_artifacts_toggled: { artifacts: boolean }; mcp_install_search_tools_toggled: { search_tools: boolean }; + mcp_install_tool_mode_changed: { tool_mode: "codemode" | "passthrough" }; // ── Command palette ────────────────────────────────────────────────────── command_palette_navigated: { @@ -196,6 +197,7 @@ export interface AnalyticsEvents { }; billing_manage_opened: {}; billing_cancel_plan_clicked: { plan_id: string }; + billing_payment_method_update_clicked: { has_card: boolean }; support_opened: {}; support_link_clicked: { label: string }; org_domain_added: { success: boolean }; diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index a87332e654..b87b5d8229 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -175,7 +175,7 @@ function AccountRow(props: { // below, because the remediation is a console visit, not a reconnect. const misconfigured = status === "misconfigured"; const needsHealthAttention = status === "expired" || status === "degraded"; - const healthDetail = needsHealthAttention ? probe?.detail : undefined; + const healthDetail = needsHealthAttention || status === "unknown" ? probe?.detail : undefined; const missingOAuthScopes = connection.missingOAuthScopes ?? []; const handleCheck = async () => { @@ -204,7 +204,7 @@ function AccountRow(props: { } else if (exit.value.status === "degraded") { toast.warning(exit.value.detail ?? "Connection check returned an error"); } else { - toast.message("No health check is configured for this integration"); + toast.message(exit.value.detail ?? "Health check did not run"); } }; diff --git a/packages/react/src/components/health-check-editor.tsx b/packages/react/src/components/health-check-editor.tsx index ea6076fb6e..f3c41257d5 100644 --- a/packages/react/src/components/health-check-editor.tsx +++ b/packages/react/src/components/health-check-editor.tsx @@ -22,9 +22,11 @@ import { import { healthCheckWriteKeys } from "../api/reactivity-keys"; import { messageFromExit } from "../api/error-reporting"; import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display"; +import { formatHealthCheckArgs, parseHealthCheckArgs } from "../lib/health-check-args"; import { Button } from "./button"; import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { Input } from "./input"; +import { Textarea } from "./textarea"; import { Label } from "./label"; import { NativeSelect, NativeSelectOption } from "./native-select"; import { @@ -70,11 +72,14 @@ export interface HealthCheckLivePreview { }>; } -/** "GET /users/me" style label for a candidate, with a writes marker so a - * mutating operation picked as a health check reads as the hazard it is. */ +export const isUnsupportedHealthCheck = (candidate: HealthCheckCandidate | null): boolean => + candidate?.destructive === true && candidate.method.toLowerCase() !== "post"; + +/** POST may be a read operation in an HTTP RPC API, but needs a warning. */ const candidateLabel = (candidate: HealthCheckCandidate): string => { const head = `${candidate.method.toUpperCase()} ${candidate.operation}`; - return candidate.destructive ? `${head} (writes)` : head; + if (candidate.method.toLowerCase() === "post") return `${head} (may change data)`; + return isUnsupportedHealthCheck(candidate) ? `${head} (unsupported for health checks)` : head; }; /** The summary line for the configured spec: the operation, prefixed with its @@ -137,8 +142,8 @@ function HealthCheckConfigFields(props: { [selected], ); - const requiredParams = useMemo( - () => (selected?.parameters ?? []).filter((p) => p.required), + const pinnedParams = useMemo( + () => (selected?.parameters ?? []).filter((p) => p.required || p.location === "body"), [selected], ); @@ -158,40 +163,64 @@ function HealthCheckConfigFields(props: { {selected?.summary ? (

{selected.summary}

) : null} - {selected?.destructive ? ( + {selected?.method.toLowerCase() === "post" ? ( +

+ POST requests can change data. Health checks run automatically and repeatedly; choose an + operation that only reads data. +

+ ) : isUnsupportedHealthCheck(selected) ? (

- This operation writes data. Prefer a read-only (GET) operation for a health check. + This method is not supported for health checks. Pick a read-only operation.

) : null}
- {requiredParams.length > 0 ? ( + {pinnedParams.length > 0 ? (
-

Required arguments

+

Pinned arguments

Pinned into every probe. An identity endpoint often needs a fixed value here (for example resourceName ={" "} people/me).

- {requiredParams.map((param) => ( + {pinnedParams.map((param) => (
- ) => - props.onArgChange(param.name, e.target.value) - } - placeholder={param.description ?? `Value for ${param.name}`} - disabled={disabled} - /> + {param.location === "body" ? ( + <> +