diff --git a/.changeset/mcp-one-hour-active-work.md b/.changeset/mcp-one-hour-active-work.md new file mode 100644 index 0000000000..ca27109eed --- /dev/null +++ b/.changeset/mcp-one-hour-active-work.md @@ -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. diff --git a/e2e/selfhost/mcp-elicitation-deadline.test.ts b/e2e/selfhost/mcp-elicitation-deadline.test.ts index eaf925554a..94c9e77ef2 100644 --- a/e2e/selfhost/mcp-elicitation-deadline.test.ts +++ b/e2e/selfhost/mcp-elicitation-deadline.test.ts @@ -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", { diff --git a/packages/plugins/mcp/src/sdk/invoke.test.ts b/packages/plugins/mcp/src/sdk/invoke.test.ts index bb48e9908a..d7ab5dd59c 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -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 => @@ -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); @@ -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"); }); diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index ed67dd2312..142de9f2ff 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -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 = {