Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e839525
test(host-cloudflare): classify toolkit MCP paths
donmasakayan Sep 2, 2026
5aecad4
fix(cloudflare): bind MCP sessions to their resource
donmasakayan Sep 2, 2026
bf2bc48
fix(host-cloudflare): serve toolkit MCP routes
donmasakayan Sep 2, 2026
38fb43b
style: format toolkit route changes
donmasakayan Sep 2, 2026
3c0f641
Merge remote-tracking branch 'origin/main' into codex/host-cloudflare…
donmasakayan Sep 3, 2026
234e620
fix(mcp): pause active timeout during elicitation
mikemikimike Sep 6, 2026
43edcee
Carry an approval's persistence choice through elicitation
SunkenInTime Sep 11, 2026
25a94d2
Preserve approval lifetime through browser and cloud resume paths
RhysSullivan Sep 12, 2026
4d8eb8d
Merge remote-tracking branch 'origin/main' into review/pr-1976
RhysSullivan Sep 12, 2026
e02f40c
Test approval waits beyond the MCP active-work deadline
RhysSullivan Sep 12, 2026
690546a
Merge remote-tracking branch 'origin/main' into review/pr-1956
RhysSullivan Sep 12, 2026
d13cc9e
Test queue timeout with a controlled clock
RhysSullivan Sep 12, 2026
7dbc52c
Test queue timeout with a controlled clock
RhysSullivan Sep 12, 2026
69cfc5d
Merge remote-tracking branch 'origin/main' into batch25-pr-1926
RhysSullivan Sep 12, 2026
3731ebd
Combine elicitation deadline and approval persistence
RhysSullivan Sep 12, 2026
7f200b4
Verify toolkit session isolation across resources and methods
RhysSullivan Sep 12, 2026
04ca4df
Merge commit '3731ebd26e23d84d9188658d0b42e848f62789bf' into batch25-…
RhysSullivan Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/computer-use-remembered-approvals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@executor-js/sdk": patch
"@executor-js/execution": patch
"@executor-js/plugin-mcp": patch
"@executor-js/api": patch
"@executor-js/react": patch
"@executor-js/host-mcp": patch
"@executor-js/cloudflare": patch
"executor": patch
---

Carry an approval's persistence choice through elicitation, so Codex Computer Use stops asking to use the same app on every call.

Computer Use offers `persist: ["session", "always"]` in the prompt's terms and remembers the app only when the answer names one. Executor dropped the offer on the way in (the terms projection kept strings only) and the choice on the way out (every adapter rebuilt the reply from `action` and `content`), so each accept was one-time. `ElicitationResponse` now has `meta.persist`; the MCP plugin, the app-server bridge, and the MCP host pass it through; the model-mode `resume` tool and the browser approval page let the approver pick from the offered scopes. Nothing is chosen automatically: a bare accept still approves once.
5 changes: 5 additions & 0 deletions .changeset/mcp-elicitation-active-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-mcp": patch
---

Exclude time spent waiting for elicitation from the MCP tool invocation deadline.
1 change: 1 addition & 0 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
{
action: payload.action,
content: payload.content as Record<string, unknown> | undefined,
...(payload.action === "accept" && payload.persist !== undefined
? { meta: { persist: payload.persist } }
: {}),
},
),
);
Expand Down
14 changes: 9 additions & 5 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,19 @@ export const makeCloudMcpAgentHandler = () => {
});
}

const resource = resourceFromPath(request);

if (sessionId) {
let owner: "ok" | "not_found" | "forbidden" | "terminated";
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a Durable Object stub RPC rejects with a plain platform Error, never a typed failure
try {
owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
});
owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner(
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
resource,
);
} catch (error) {
// The sibling stub touchpoints in this handler are both guarded — the
// `_cf_scheduleDestroy` call above with `Effect.ignore`, the
Expand Down Expand Up @@ -290,7 +295,6 @@ export const makeCloudMcpAgentHandler = () => {
}
}

const resource = resourceFromPath(request);
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withOrgWriteAccess(
Expand Down
11 changes: 10 additions & 1 deletion apps/cloud/src/mcp/session-build-semaphore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach } from "@effect/vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";

import {
acquireBuildSlot,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
Expand Down
6 changes: 5 additions & 1 deletion apps/cloud/src/routes/app/resume.$executionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ function CloudMcpResumeApproval(props: { executionId: string; mcpSessionId: stri
executionId: string,
action: "accept" | "decline" | "cancel",
content?: Record<string, unknown>,
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],
);
Expand Down
38 changes: 24 additions & 14 deletions apps/host-cloudflare/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { Effect, Predicate } from "effect";
import {
McpAuthProvider,
jsonRpcErrorBody,
defaultMcpResource,
orgWriteAccessForPrincipal,
withOrgWriteAccess,
type AuthOutcome,
type McpResource,
type Principal,
} from "@executor-js/host-mcp";
import {
Expand All @@ -22,6 +22,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 =>
Expand Down Expand Up @@ -75,6 +76,7 @@ const authenticate = (request: Request, config: CloudflareConfig) =>
const propsForPrincipal = (
request: Request,
principal: Principal,
resource: McpResource,
): Effect.Effect<McpSessionProps> =>
Effect.gen(function* () {
const propagation = yield* currentPropagationHeaders(request);
Expand All @@ -86,21 +88,20 @@ 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,
};
});

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<Response> => {
if (request.method === "OPTIONS") return corsPreflightResponse();
Expand All @@ -120,15 +121,23 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
return renderAuthError(auth, request, outcome);
}

const resource = mcpResourceFromPath(new URL(request.url).pathname);
if (resource === null) {
return jsonRpcResponse(404, -32001, "MCP route not found");
}

if (!sessionId && request.method === "DELETE") {
return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
}

if (sessionId) {
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
});
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner(
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
resource,
);
if (owner === "not_found") {
return jsonRpcResponse(404, -32001, "Session not found");
}
Expand All @@ -142,7 +151,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
}
}

const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withOrgWriteAccess(
withVerifiedIdentityHeaders(
Expand All @@ -151,10 +160,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
defaultMcpResource,
resource,
),
orgWriteAccessForPrincipal(outcome.principal),
);
return serve.fetch(forwarded, env, ctx);
const target = resource.kind === "toolkit" ? serveToolkit : serveDefault;
return target.fetch(forwarded, env, ctx);
};
};
29 changes: 29 additions & 0 deletions apps/host-cloudflare/src/mcp/resource.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
8 changes: 8 additions & 0 deletions apps/host-cloudflare/src/mcp/resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";

export const mcpResourceFromPath = (pathname: string): McpResource | null => {
if (pathname === "/mcp") return defaultMcpResource;

const toolkitMatch = /^\/mcp\/toolkits\/([^/]+)$/.exec(pathname);
return toolkitMatch?.[1] ? { kind: "toolkit", slug: toolkitMatch[1] } : null;
};
96 changes: 96 additions & 0 deletions apps/host-cloudflare/src/worker.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,102 @@ 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);

const reusedOnOtherToolkit = await rpc(`${toolkitPath}-other`, sessionId, {
jsonrpc: "2.0",
id: 4,
method: "tools/list",
});
expect(reusedOnOtherToolkit.status).toBe(403);
for (const method of ["GET", "DELETE"]) {
const response = await worker.fetch("/mcp", {
method,
headers: { accept, "mcp-session-id": sessionId! },
});
expect(response.status).toBe(403);
}
const stillUsable = await rpc(toolkitPath, sessionId, {
jsonrpc: "2.0",
id: 5,
method: "tools/list",
});
expect(stillUsable.status).toBe(200);
const deleted = await worker.fetch(toolkitPath, {
method: "DELETE",
headers: { accept, "mcp-session-id": sessionId! },
});
expect(deleted.status).toBe(204);
}, 60_000);

it("serves streamable HTTP GET only for initialized sessions", async () => {
const missing = await worker.fetch("/mcp", {
method: "GET",
Expand Down
Loading
Loading