Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/mcp-one-hour-active-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-mcp": patch
---

An upstream MCP tool call now has one hour of active work before it is cut off, up from 60 seconds, so a single long tool call finishes. The deadline still pauses while an elicitation waits for input, and one hour still bounds a hung upstream.
5 changes: 3 additions & 2 deletions e2e/selfhost/mcp-elicitation-deadline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ scenario(
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.
// The production active-work deadline is one hour, too long to cross
// here. The unit tests in plugin-mcp prove the deadline pauses; this
// checks that a delayed approval still resumes with its chosen lifetime.
yield* Effect.sleep("65 seconds");
const executionId = decodeExecutionId(/\bexecutionId:\s*(\S+)/.exec(paused.text)?.[1]);
const completed = yield* session.call("resume", {
Expand Down
20 changes: 17 additions & 3 deletions packages/plugins/mcp/src/sdk/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ beforeAll(() => loadMcpClientSdk());
import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";
import { invokeMcpTool, makeActiveWorkDeadline, MCP_ACTIVE_WORK_TIMEOUT_MS } from "./invoke";

const ONE_HOUR_MS = 3_600_000;

const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" }));

const rejectingConnector = (cause: unknown): McpConnector =>
Expand Down Expand Up @@ -167,6 +169,18 @@ const invocationRejectionCases = [
describe("invokeMcpTool", () => {
afterEach(() => vi.useRealTimers());

it("defaults the active-work deadline to one hour", () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
expect(MCP_ACTIVE_WORK_TIMEOUT_MS).toBe(3_600_000);
const deadline = makeActiveWorkDeadline();

vi.advanceTimersByTime(3_599_999);
expect(deadline.signal.aborted).toBe(false);
vi.advanceTimersByTime(1);
expect(deadline.signal.aborted).toBe(true);
deadline.dispose();
});

it("pauses the active-work deadline across overlapping elicitations", () => {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
const deadline = makeActiveWorkDeadline(100);
Expand Down Expand Up @@ -249,14 +263,14 @@ describe("invokeMcpTool", () => {
);

await elicitationStarted;
expect(callOptions?.timeout).toBeGreaterThan(MCP_ACTIVE_WORK_TIMEOUT_MS);
vi.advanceTimersByTime(MCP_ACTIVE_WORK_TIMEOUT_MS);
expect(callOptions?.timeout).toBeGreaterThan(ONE_HOUR_MS);
vi.advanceTimersByTime(ONE_HOUR_MS);
expect(callOptions?.signal.aborted).toBe(false);

finishElicitation!();
await Promise.resolve();
await Promise.resolve();
vi.advanceTimersByTime(MCP_ACTIVE_WORK_TIMEOUT_MS);
vi.advanceTimersByTime(ONE_HOUR_MS);
expect(callOptions?.signal.aborted).toBe(true);
expect(await invocation).toBe("failed");
});
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/mcp/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
* 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;
export const MCP_ACTIVE_WORK_TIMEOUT_MS = 3_600_000;
const MCP_SDK_TIMEOUT_BACKSTOP_MS = 2_147_483_647;

export type ActiveWorkDeadline = {
Expand Down
Loading