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/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/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..d99f55f0d3 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -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/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/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/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/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/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/execution/src/engine.test.ts b/packages/core/execution/src/engine.test.ts index 06b9e20888..8467993e0b 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: {} })), diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 8bb9bda071..89707f64e7 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 @@ -215,10 +223,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 +256,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)}`); } @@ -798,6 +816,7 @@ export const createExecutionEngine = { + 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,6 +70,8 @@ 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; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index d8ac973134..f415b32023 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -223,6 +223,8 @@ export { sanitizeArtifactPreviewMarkup, ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./ // Elicitation. export { ElicitationMeta, + ElicitationResponseMeta, + offeredPersistence, FormElicitation, UrlElicitation, ElicitationAction, diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 3dbae924b8..d798d57697 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -357,6 +357,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 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..200e5e081a 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -20,10 +20,9 @@ import { 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 { decodeResumeResponse } 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"; @@ -205,7 +204,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); diff --git a/packages/hosts/mcp/src/browser-approval.ts b/packages/hosts/mcp/src/browser-approval.ts index a524b89fbb..934ddf6fae 100644 --- a/packages/hosts/mcp/src/browser-approval.ts +++ b/packages/hosts/mcp/src/browser-approval.ts @@ -110,13 +110,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/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 1b7bb8aa45..f23cffc4d6 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 }) => @@ -1653,6 +1655,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..74535797d4 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -32,6 +32,7 @@ import type { ArtifactBinding, ArtifactSummary, ElicitationResponse, + ElicitationResponseMeta, ElicitationHandler, ElicitationContext, ElicitationRequest, @@ -380,6 +381,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 +391,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 +402,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 +457,7 @@ const makeMcpElicitationHandler = { relatedRequestId }, ); + const meta = answeredTerms(response._meta); debugLog?.("elicitation.response", { requestTag, action: response.action, @@ -450,11 +465,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) => @@ -1409,8 +1426,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 +1436,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 +1471,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, }, }), @@ -1612,11 +1629,25 @@ export const createExecutorMcpServer = ( .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 }, extra) => + ({ executionId, action, content: rawContent, persist }, extra) => runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + resumeExecution( + executionId, + { + action, + content: parseJsonContent(rawContent), + ...(persist === undefined ? {} : { meta: { persist } }), + }, + extra, + ), extra, ), ); @@ -2309,7 +2340,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/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/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..4e480f54db 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -537,6 +537,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/react/src/pages/resume-approval.tsx b/packages/react/src/pages/resume-approval.tsx index 424e8046f9..12efe18a91 100644 --- a/packages/react/src/pages/resume-approval.tsx +++ b/packages/react/src/pages/resume-approval.tsx @@ -5,11 +5,15 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { Check, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { offeredPersistence } from "@executor-js/sdk"; + import { pausedExecutionAtom, resumeExecution } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { Button } from "../components/button"; import { CopyButton } from "../components/copy-button"; import { type ElicitationAction, useElicitationApproval } from "../components/elicitation-approval"; +import { Label } from "../components/label"; +import { NativeSelect, NativeSelectOption } from "../components/native-select"; import { Skeleton } from "../components/skeleton"; type PausedExecutionInfo = { readonly text: string; readonly structured: unknown }; @@ -52,6 +56,16 @@ type PausedInteractionView = { readonly url: string | null; readonly requestedSchema: unknown; readonly toolId: string | null; + /** Scopes the upstream offers to remember an approval for; empty when + * accepting is one-time and there is nothing to choose. */ + readonly offeredPersistence: readonly string[]; +}; + +/** Labels for the persistence scopes Codex plugins use. An unfamiliar scope + * is shown as the upstream spelled it rather than hidden. */ +const persistenceLabel: Record = { + session: "For this session", + always: "Always", }; const encodeJsonPreview = Schema.encodeUnknownOption(Schema.UnknownFromJsonString); @@ -63,6 +77,7 @@ const PausedInteractionInfo = Schema.Struct({ url: Schema.optional(Schema.String), requestedSchema: Schema.optional(Schema.Unknown), toolId: Schema.optional(Schema.String), + meta: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }); const PausedStructured = Schema.Struct({ executionId: Schema.optional(Schema.String), @@ -111,6 +126,7 @@ const interactionFromPausedInfo = (paused: PausedExecutionInfo): PausedInteracti url: interaction.url ?? null, requestedSchema: interaction.requestedSchema, toolId: interaction.toolId ?? null, + offeredPersistence: offeredPersistence(interaction.meta), }; }; @@ -122,10 +138,18 @@ export function ResumeApprovalPage(props: { executionId: string }) { const doResume = useAtomSet(resumeExecution, { mode: "promiseExit" }); const resume = useCallback( - (executionId: string, action: ElicitationAction, content?: Record) => + ( + executionId: string, + action: ElicitationAction, + content?: Record, + persist?: string, + ) => doResume({ params: { executionId }, - payload: action === "accept" ? { action, content: content ?? {} } : { action }, + payload: + action === "accept" + ? { action, content: content ?? {}, ...(persist === undefined ? {} : { persist }) } + : { action }, }), [doResume], ); @@ -140,6 +164,7 @@ export function ResumeApprovalPageView(props: { executionId: string, action: ElicitationAction, content?: Record, + persist?: string, ) => Promise>; unavailableMessage?: string; }) { @@ -147,6 +172,9 @@ export function ResumeApprovalPageView(props: { const [status, setStatus] = useState({ state: "idle" }); const [currentExecutionId, setCurrentExecutionId] = useState(executionId); const [nextPaused, setNextPaused] = useState(null); + // "" is the one-time approval; anything else is a scope the upstream + // offered. Reset with the execution, since the next pause may offer none. + const [persist, setPersist] = useState(""); const displayedPaused = nextPaused ?? (AsyncResult.isSuccess(paused) ? paused.value : null); const approval = useElicitationApproval(requestedSchemaFromPausedInfo(displayedPaused)); const interaction = displayedPaused ? interactionFromPausedInfo(displayedPaused) : null; @@ -154,6 +182,7 @@ export function ResumeApprovalPageView(props: { useEffect(() => { setCurrentExecutionId(executionId); setNextPaused(null); + setPersist(""); setStatus({ state: "idle" }); }, [executionId]); @@ -171,7 +200,12 @@ export function ResumeApprovalPageView(props: { if (content === null) return; setStatus({ state: "submitting", action }); - const exit = await resume(currentExecutionId, action, content); + const exit = await resume( + currentExecutionId, + action, + content, + action === "accept" && persist !== "" ? persist : undefined, + ); if (Exit.isFailure(exit)) { trackEvent("resume_approval_submitted", { @@ -208,6 +242,7 @@ export function ResumeApprovalPageView(props: { }); setCurrentExecutionId(nextExecutionId); setNextPaused({ text: exit.value.text, structured: exit.value.structured }); + setPersist(""); setStatus({ state: "idle" }); return; } @@ -224,7 +259,7 @@ export function ResumeApprovalPageView(props: { text: exit.value.text || "The paused execution has been resumed.", }); }, - [approval, currentExecutionId, interaction, resume], + [approval, currentExecutionId, interaction, persist, resume], ); const busy = status.state === "submitting"; @@ -254,7 +289,12 @@ export function ResumeApprovalPageView(props: {
{nextPaused ? ( - + ) : ( AsyncResult.match(paused, { onInitial: () => ( @@ -271,7 +311,12 @@ export function ResumeApprovalPageView(props: {
), onSuccess: () => ( - + ), }) )} @@ -355,9 +400,13 @@ export function ResumeApprovalPageView(props: { function PendingRequestDetails({ interaction, approvalFields, + persist, + onPersistChange, }: { interaction: PausedInteractionView | null; approvalFields: ReactNode; + persist: string; + onPersistChange: (persist: string) => void; }) { if (!interaction) { return
No pending request details found.
; @@ -402,6 +451,24 @@ function PendingRequestDetails({ {approvalFields && (
{approvalFields}
)} + + {interaction.offeredPersistence.length > 0 && ( +
+ + onPersistChange(event.target.value)} + > + Just this once + {interaction.offeredPersistence.map((scope) => ( + + {persistenceLabel[scope] ?? scope} + + ))} + +
+ )} ); } diff --git a/packages/react/src/routes/resume.$executionId.tsx b/packages/react/src/routes/resume.$executionId.tsx index e62cfc5274..e9b90283e2 100644 --- a/packages/react/src/routes/resume.$executionId.tsx +++ b/packages/react/src/routes/resume.$executionId.tsx @@ -85,6 +85,7 @@ type LocalMcpResumeInput = { readonly executionId: string; readonly action: ElicitationAction; readonly content?: Record; + readonly persist?: string; }; const resumeLocalMcpExecution = Atom.fn()((input) => @@ -106,7 +107,11 @@ const resumeLocalMcpExecution = Atom.fn()((input) => }, body: JSON.stringify( input.action === "accept" - ? { action: input.action, content: input.content ?? {} } + ? { + action: input.action, + content: input.content ?? {}, + ...(input.persist === undefined ? {} : { persist: input.persist }), + } : { action: input.action }, ), }, @@ -159,12 +164,18 @@ function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: stri ); const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" }); const resume = useCallback( - (executionId: string, action: ElicitationAction, content?: Record) => + ( + executionId: string, + action: ElicitationAction, + content?: Record, + persist?: string, + ) => doResume({ mcpSessionId: props.mcpSessionId, executionId, action, content, + persist, }), [doResume, props.mcpSessionId], );