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
86 changes: 86 additions & 0 deletions src/mcp/client-auth-reauth-cap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | undefined;
const abortTimeout = new Promise<never>((_, 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) => {
Expand Down Expand Up @@ -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<typeof setTimeout> | undefined;
const abortTimeout = new Promise<never>((_, 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),
Expand Down
13 changes: 7 additions & 6 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
32 changes: 32 additions & 0 deletions src/mcp/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>((_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");
});
});
Loading