From e839525de676f168c85ddcd48a4f25d076e78d18 Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:03:07 +1200 Subject: [PATCH 01/11] test(host-cloudflare): classify toolkit MCP paths --- apps/host-cloudflare/src/mcp/resource.test.ts | 29 +++++++++++++++++++ apps/host-cloudflare/src/mcp/resource.ts | 10 +++++++ 2 files changed, 39 insertions(+) create mode 100644 apps/host-cloudflare/src/mcp/resource.test.ts create mode 100644 apps/host-cloudflare/src/mcp/resource.ts 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..9d1d45f905 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/resource.ts @@ -0,0 +1,10 @@ +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; +}; From 5aecad4b60f7df091f5053a3b00226dcb177a456 Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:08:14 +1200 Subject: [PATCH 02/11] fix(cloudflare): bind MCP sessions to their resource --- apps/cloud/src/mcp/agent-handler.ts | 17 +++++--- apps/host-cloudflare/src/mcp/agent-handler.ts | 43 +++++++++++++------ .../mcp/agent-session-durable-object.test.ts | 37 ++++++++++++---- .../src/mcp/agent-session-durable-object.ts | 17 +++++--- .../hosts/cloudflare/src/mcp/session-stub.ts | 2 + 5 files changed, 84 insertions(+), 32 deletions(-) diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 0ec697c911..fb373db576 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -250,14 +250,22 @@ 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 @@ -286,7 +294,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 = withVerifiedIdentityHeaders( diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index a870277401..c069d45f3c 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -3,8 +3,8 @@ import { Effect, Predicate } from "effect"; import { McpAuthProvider, jsonRpcErrorBody, - defaultMcpResource, type AuthOutcome, + type McpResource, type Principal, } from "@executor-js/host-mcp"; import { @@ -19,6 +19,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 => @@ -72,6 +73,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); @@ -82,10 +84,7 @@ 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, + resource, webOrigin: new URL(request.url).origin, }, propagation, @@ -93,10 +92,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(); @@ -116,15 +117,26 @@ 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"); } @@ -138,7 +150,9 @@ 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 = withVerifiedIdentityHeaders( request, @@ -146,8 +160,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }, - defaultMcpResource, + resource, ); - return serve.fetch(forwarded, env, ctx); + const target = resource.kind === "toolkit" ? serveToolkit : serveDefault; + return target.fetch(forwarded, env, ctx); }; }; 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 37c6b05107..b9aa7679af 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 @@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js"; -import { defaultMcpResource } from "@executor-js/host-mcp"; +import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; import { @@ -161,7 +161,7 @@ type HarnessSession = { validateMcpSessionOwner: (identity: { readonly accountId: string; readonly organizationId: string; - }) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; + }, resource: McpResource) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; }; class StaleCloseTransport implements Transport { @@ -539,10 +539,28 @@ 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(); @@ -566,11 +584,11 @@ describe("McpAgentSessionDOBase transport restore", () => { 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(); @@ -602,7 +620,7 @@ describe("McpAgentSessionDOBase transport restore", () => { const restore = session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1", - }); + }, defaultMcpResource); const sdkStart = session.onStart(); await firstStartEntered.promise; @@ -710,7 +728,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 = { @@ -783,7 +801,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 dcdfc90519..f3f36198db 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -18,7 +18,11 @@ import { type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; +import { + defaultMcpResource, + mcpResourceKey, + type McpResource, +} from "@executor-js/host-mcp"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors"; @@ -1533,6 +1537,7 @@ export abstract class McpAgentSessionDOBase< async validateMcpSessionOwner( identity: McpApprovalOwner, + resource: McpResource, ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { const self = this; return Effect.runPromise( @@ -1561,10 +1566,12 @@ 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/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 2e17f3a412..f2e9139240 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, @@ -17,6 +18,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: ( From bf2bc48d91808047120097151e200a317ba7a68d Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:08:48 +1200 Subject: [PATCH 03/11] fix(host-cloudflare): serve toolkit MCP routes --- .../src/worker.e2e.node.test.ts | 71 +++++++++++++++++++ apps/host-cloudflare/src/worker.ts | 10 +-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 738a358827..f64919c06b 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -302,6 +302,77 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { 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); + }, 60_000); + it("serves streamable HTTP GET only for initialized sessions", async () => { const missing = await worker.fetch("/mcp", { method: "GET", 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); From 38fb43bb7076214bd1480e9726eeb4dffa5c3496 Mon Sep 17 00:00:00 2001 From: Don Pansacola <1178461+donmasakayan@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:21:24 +1200 Subject: [PATCH 04/11] style: format toolkit route changes --- apps/cloud/src/mcp/agent-handler.ts | 5 +- apps/host-cloudflare/src/mcp/agent-handler.ts | 9 +--- apps/host-cloudflare/src/mcp/resource.ts | 4 +- .../mcp/agent-session-durable-object.test.ts | 48 +++++++++++-------- .../src/mcp/agent-session-durable-object.ts | 9 +--- 5 files changed, 35 insertions(+), 40 deletions(-) diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index fb373db576..ad424bf80f 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -256,10 +256,7 @@ export const makeCloudMcpAgentHandler = () => { 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( + owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner( { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index c069d45f3c..fef2c2780c 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -127,10 +127,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } if (sessionId) { - const owner = await mcpSessionStub( - env.MCP_SESSION, - sessionId, - ).validateMcpSessionOwner( + const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner( { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, @@ -150,9 +147,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } } - const props = await Effect.runPromise( - propsForPrincipal(request, outcome.principal, resource), - ); + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; const forwarded = withVerifiedIdentityHeaders( request, diff --git a/apps/host-cloudflare/src/mcp/resource.ts b/apps/host-cloudflare/src/mcp/resource.ts index 9d1d45f905..193a3efc49 100644 --- a/apps/host-cloudflare/src/mcp/resource.ts +++ b/apps/host-cloudflare/src/mcp/resource.ts @@ -4,7 +4,5 @@ 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; + return toolkitMatch?.[1] ? { kind: "toolkit", slug: toolkitMatch[1] } : null; }; 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 b9aa7679af..b0354288ab 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 @@ -158,10 +158,13 @@ type HarnessSession = { identity: McpApprovalOwner, response: ResumeResponse, ) => Promise; - validateMcpSessionOwner: (identity: { - readonly accountId: string; - readonly organizationId: string; - }, resource: McpResource) => 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 { @@ -550,9 +553,7 @@ describe("McpAgentSessionDOBase transport restore", () => { 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, defaultMcpResource)).resolves.toBe("ok"); await expect( session.validateMcpSessionOwner(identity, { kind: "toolkit", @@ -581,14 +582,20 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); - const first = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }, defaultMcpResource); - const second = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }, defaultMcpResource); + 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(); @@ -617,10 +624,13 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); - const restore = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }, defaultMcpResource); + const restore = session.validateMcpSessionOwner( + { + accountId: "user-1", + organizationId: "org-1", + }, + defaultMcpResource, + ); const sdkStart = session.onStart(); await firstStartEntered.promise; 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 f3f36198db..3055b060af 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -18,11 +18,7 @@ import { type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { - defaultMcpResource, - mcpResourceKey, - type McpResource, -} from "@executor-js/host-mcp"; +import { defaultMcpResource, mcpResourceKey, type McpResource } from "@executor-js/host-mcp"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors"; @@ -1569,8 +1565,7 @@ export abstract class McpAgentSessionDOBase< const ownerMatches = identity.accountId === sessionMeta.userId && identity.organizationId === sessionMeta.organizationId; - const resourceMatches = - mcpResourceKey(resource) === mcpResourceKey(sessionMeta.resource); + const resourceMatches = mcpResourceKey(resource) === mcpResourceKey(sessionMeta.resource); return ownerMatches && resourceMatches ? ("ok" as const) : ("forbidden" as const); }).pipe( Effect.withSpan("McpSessionDO.validateMcpSessionOwner"), From 234e620ad2de981002be31067da68e1d95ef2d2f Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 6 Sep 2026 16:49:20 +0800 Subject: [PATCH 05/11] fix(mcp): pause active timeout during elicitation --- packages/plugins/mcp/src/sdk/invoke.test.ts | 144 +++++++++++++++- packages/plugins/mcp/src/sdk/invoke.ts | 180 ++++++++++++++++---- 2 files changed, 290 insertions(+), 34 deletions(-) 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..a7596fc3ae 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); @@ -156,36 +245,53 @@ 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; + 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 }; + } + } + // 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 +324,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 +372,7 @@ const useConnection = ( attributes: { "mcp.tool.name": toolName }, }), ); - }); + }).pipe(Effect.scoped); // --------------------------------------------------------------------------- // Public API From 43edcee2fe68dc73b17ee4049fd1238b165a92d5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 11 Sep 2026 15:14:03 -0400 Subject: [PATCH 06/11] Carry an approval's persistence choice through elicitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex Computer Use offers `persist: ["session", "always"]` in the terms of its "Allow Computer Use to use X?" prompt and remembers the app only when the answer names one. Executor lost the offer on the way in — the terms projection kept strings only — and the choice on the way out, because every adapter rebuilt the reply from `action` and `content`. So each accept was a one-time approval and the same app prompted on every call. - `ElicitationResponse.meta.persist` carries the choice; the vocabulary is closed so no host can grant more than the prompt offered. - `approvalTerms` keeps string lists, so the offered scopes reach the host. - The MCP plugin, the app-server bridge, and the MCP host (native mode) pass `_meta` through in both directions. - The model-mode `resume` tool takes `persist`; the pause output names the offered scopes and says a bare accept is one-time. - The HTTP resume API takes `persist`, and the browser approval page offers the scopes in a select. Nothing is chosen automatically. Fixes #1962 --- .../computer-use-remembered-approvals.md | 12 +++ packages/core/api/src/executions/api.ts | 4 + packages/core/api/src/handlers/executions.ts | 1 + packages/core/execution/src/engine.test.ts | 28 ++++++ packages/core/execution/src/engine.ts | 25 +++++- packages/core/sdk/src/elicitation.ts | 27 ++++++ packages/core/sdk/src/index.ts | 2 + packages/hosts/mcp/src/tool-server.test.ts | 90 ++++++++++++++++++- packages/hosts/mcp/src/tool-server.ts | 55 +++++++++--- .../mcp/src/sdk/appserver-connector.test.ts | 33 +++++++ .../mcp/src/sdk/appserver-connector.ts | 8 ++ .../mcp/src/sdk/appserver-test-server.ts | 46 +++++++++- .../mcp/src/sdk/codex-plugin-presets.test.ts | 12 ++- .../plugins/mcp/src/sdk/elicitation.test.ts | 44 +++++++++ packages/plugins/mcp/src/sdk/invoke.ts | 16 +++- packages/plugins/mcp/src/testing/server.ts | 31 +++++++ packages/react/src/pages/resume-approval.tsx | 79 ++++++++++++++-- 17 files changed, 486 insertions(+), 27 deletions(-) create mode 100644 .changeset/computer-use-remembered-approvals.md diff --git a/.changeset/computer-use-remembered-approvals.md b/.changeset/computer-use-remembered-approvals.md new file mode 100644 index 0000000000..fc52239d20 --- /dev/null +++ b/.changeset/computer-use-remembered-approvals.md @@ -0,0 +1,12 @@ +--- +"@executor-js/sdk": patch +"@executor-js/execution": patch +"@executor-js/plugin-mcp": patch +"@executor-js/api": patch +"@executor-js/react": 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/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/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.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 4b7a433c7c..333ca13383 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -129,12 +129,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 } : {}; @@ -167,11 +176,16 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic const exit = await Effect.runPromiseExit(elicit(req)); 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) } : {}), + // The answer's terms ride back the way the request's came: in + // `_meta`. Exactly the chosen scope and nothing else, so the wire + // carries no more than the contract names; a decline carries none. + ...(persist === undefined ? {} : { _meta: { persist } }), }; } const failure = exit.cause.reasons.find(Cause.isFailReason); 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} + + ))} + +
+ )} ); } From 25a94d236f1e9b022f54d3127761c28bcde558c5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:04:43 -0700 Subject: [PATCH 07/11] Preserve approval lifetime through browser and cloud resume paths --- .../computer-use-remembered-approvals.md | 2 + apps/cloud/src/auth/api.ts | 1 + apps/cloud/src/auth/handlers.ts | 3 + .../src/routes/app/resume.$executionId.tsx | 6 +- .../mcp-approval-persistence.test.ts | 80 +++++++++++++++++++ e2e/vitest.config.ts | 1 + .../mcp/agent-session-durable-object.test.ts | 17 ++++ .../src/mcp/agent-session-durable-object.ts | 9 +-- packages/hosts/mcp/src/browser-approval.ts | 13 ++- .../react/src/routes/resume.$executionId.tsx | 15 +++- 10 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 e2e/scenarios/mcp-approval-persistence.test.ts diff --git a/.changeset/computer-use-remembered-approvals.md b/.changeset/computer-use-remembered-approvals.md index fc52239d20..50ab5b6d7e 100644 --- a/.changeset/computer-use-remembered-approvals.md +++ b/.changeset/computer-use-remembered-approvals.md @@ -4,6 +4,8 @@ "@executor-js/plugin-mcp": patch "@executor-js/api": patch "@executor-js/react": patch +"@executor-js/host-mcp": patch +"@executor-js/cloudflare": patch "executor": patch --- 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/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/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/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 4c38d7a606..d7b4e87f90 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 @@ -337,6 +337,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 993fab3783..b792e6e396 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"; @@ -206,7 +205,7 @@ const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000; const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; 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/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], ); From e02f40c2235ed592efe9d63e0d7d1e0b188c3a9b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:05:17 -0700 Subject: [PATCH 08/11] Test approval waits beyond the MCP active-work deadline --- .changeset/mcp-elicitation-active-deadline.md | 5 ++ e2e/selfhost/mcp-elicitation-deadline.test.ts | 75 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 .changeset/mcp-elicitation-active-deadline.md create mode 100644 e2e/selfhost/mcp-elicitation-deadline.test.ts 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/e2e/selfhost/mcp-elicitation-deadline.test.ts b/e2e/selfhost/mcp-elicitation-deadline.test.ts new file mode 100644 index 0000000000..f3faa11a1d --- /dev/null +++ b/e2e/selfhost/mcp-elicitation-deadline.test.ts @@ -0,0 +1,75 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } 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); + +scenario( + "MCP · a human can approve after the active-work deadline without losing the tool call", + { 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: {} }, + }, + { timeout: 150_000 }, + ); + return { content: [{ type: "text", text: `decision:${reply.action}` }] }; + }); + 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 completed = yield* session.approvePaused(paused.text); + expect(completed.ok).toBe(true); + expect(completed.text).toContain("decision:accept"); + }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.orDie))); + }), + ), +); From d13cc9e0662bf2b067d2164f31b9b775229a941e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:12:09 -0700 Subject: [PATCH 09/11] Test queue timeout with a controlled clock --- apps/cloud/src/mcp/session-build-semaphore.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 }); From 7dbc52c8c87f36087f167e37b430cca4597e4866 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:12:10 -0700 Subject: [PATCH 10/11] Test queue timeout with a controlled clock --- apps/cloud/src/mcp/session-build-semaphore.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 }); From 7f200b430ede7a137d7cbfd0d7a6c690788776f0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:38:22 -0700 Subject: [PATCH 11/11] Verify toolkit session isolation across resources and methods --- .../src/worker.e2e.node.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index f64919c06b..36b26ce863 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -371,6 +371,31 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { 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 () => {