From b7e15c17b5868a0ab48c6f8ba4f309e993f2d2e8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 08:54:33 -0700 Subject: [PATCH 1/2] test(mcp): cover abort during HTTP auth recovery on callBlocks --- src/mcp/client-auth-reauth-cap.test.ts | 86 ++++++++++++++++++++++++++ src/mcp/plugin.test.ts | 32 ++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/mcp/client-auth-reauth-cap.test.ts b/src/mcp/client-auth-reauth-cap.test.ts index d506084c6..984cac008 100644 --- a/src/mcp/client-auth-reauth-cap.test.ts +++ b/src/mcp/client-auth-reauth-cap.test.ts @@ -465,6 +465,48 @@ describe("HTTP MCP re-auth loop prevention", () => { expect(authURLCount).toBe(1); }); + test("block-path caller abort does not cancel shared recovery for another call", async () => { + finishAuthError = undefined; + callbackGate = new Promise((resolve) => { + releaseCallback = resolve; + }); + const connected = await connectMCPServer(config, { + onAuthURL: () => (authURLCount += 1), + }); + expect(connected.ok).toBe(true); + if (!connected.ok) return; + callFailuresLeft = 2; + const callBlocks = connected.client.callBlocks; + expect(callBlocks).toBeDefined(); + if (callBlocks === undefined) return; + const firstAbort = new AbortController(); + const first = callBlocks("first", {}, firstAbort.signal); + const second = callBlocks("second", {}, new AbortController().signal); + while (waitForCodeCalls === 0) await Promise.resolve(); + + let abortTimer: ReturnType | undefined; + const abortTimeout = new Promise((_, reject) => { + abortTimer = setTimeout( + () => reject(new Error("timed out waiting for caller abort")), + 1000, + ); + }); + try { + firstAbort.abort(new Error("caller stopped")); + await expect(Promise.race([first, abortTimeout])).rejects.toThrow( + "caller stopped", + ); + } finally { + if (abortTimer !== undefined) clearTimeout(abortTimer); + releaseCallback?.(); + } + + await expect(second).resolves.toEqual([]); + expect(waitForCodeCalls).toBe(1); + expect(finishAuthCalls).toBe(1); + expect(authURLCount).toBe(1); + }); + test("aborted waiter still fires onAuthorized when background finishAuth succeeds", async () => { finishAuthError = undefined; callbackGate = new Promise((resolve) => { @@ -508,6 +550,50 @@ describe("HTTP MCP re-auth loop prevention", () => { expect(authURLCount).toBe(1 + MAX_BROWSER_AUTH_ATTEMPTS); }); + test("block-path aborted waiter still fires onAuthorized when background finishAuth succeeds", async () => { + finishAuthError = undefined; + callbackGate = new Promise((resolve) => { + releaseCallback = resolve; + }); + const connected = await connectMCPServer(config, { + onAuthURL: () => (authURLCount += 1), + onAuthorized: () => (authorizedCount += 1), + }); + expect(connected.ok).toBe(true); + if (!connected.ok) return; + const callBlocks = connected.client.callBlocks; + expect(callBlocks).toBeDefined(); + if (callBlocks === undefined) return; + callFailuresLeft = 1; + const abort = new AbortController(); + const call = callBlocks("ping", {}, abort.signal); + while (authURLCount === 0 || waitForCodeCalls === 0) + await Promise.resolve(); + + let abortTimer: ReturnType | undefined; + const abortTimeout = new Promise((_, reject) => { + abortTimer = setTimeout( + () => reject(new Error("timed out waiting for caller abort")), + 1000, + ); + }); + try { + abort.abort(new Error("caller stopped")); + await expect(Promise.race([call, abortTimeout])).rejects.toThrow( + "caller stopped", + ); + } finally { + if (abortTimer !== undefined) clearTimeout(abortTimer); + releaseCallback?.(); + } + expect(authorizedCount).toBe(0); + while (finishAuthCalls === 0) await Promise.resolve(); + for (let tick = 0; tick < 20 && authorizedCount === 0; tick += 1) + await Promise.resolve(); + expect(authorizedCount).toBe(1); + expect(finishAuthCalls).toBe(1); + }); + test("refresh-only recovery clears prior browser-cap counts", async () => { const connected = await connectMCPServer(config, { onAuthURL: () => (authURLCount += 1), diff --git a/src/mcp/plugin.test.ts b/src/mcp/plugin.test.ts index 106d305d6..7a11048ee 100644 --- a/src/mcp/plugin.test.ts +++ b/src/mcp/plugin.test.ts @@ -635,4 +635,36 @@ describe("mcpClientToAgentTools", () => { expect(result.isError).toBe(true); expect(result.content).toContain("transport exploded"); }); + + test("a parked block-path call aborted mid-recovery surfaces a failed result", async () => { + const gate = skipGate(); + const client: MCPClient = { + ...fakeClient("unused"), + callBlocks: (_tool, _args, signal) => + new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + signal.addEventListener( + "abort", + () => reject(signal.reason ?? new Error("aborted")), + { once: true }, + ); + }), + }; + const [tool] = mcpClientToAgentTools(client, gate); + if (tool?.kind !== "full") throw new Error("expected full tool"); + + const controller = new AbortController(); + const pending = tool.handler( + { id: "c-mcp-abort", name: "mcp__acme__fetch_secret", arguments: {} }, + controller.signal, + ); + controller.abort(new Error("caller stopped")); + const result = await pending; + + expect(result.isError).toBe(true); + expect(result.content).toContain("caller stopped"); + }); }); From 1f7fe6c2ca9b51b710f14607cc3ece0ec242a487 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 08:55:50 -0700 Subject: [PATCH 2/2] fix(mcp): pass abort signal through callBlocks auth recovery --- src/mcp/client.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/mcp/client.ts b/src/mcp/client.ts index e804d1e5f..1ab62c430 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -613,12 +613,13 @@ async function finishClient( return envelope; }, async callBlocks(toolName, args, signal) { - const context = - authContext === undefined ? undefined : { ...authContext, signal }; - const result = await withHTTPAuthorizationRecovery(context, () => - client.callTool({ name: toolName, arguments: args }, undefined, { - signal, - }), + const result = await withHTTPAuthorizationRecovery( + authContext, + () => + client.callTool({ name: toolName, arguments: args }, undefined, { + signal, + }), + signal, ); return validateMcpContentBlocks(result.content); },