Skip to content

Commit ae995cb

Browse files
committed
Retry failed MCP connects and abort OAuth HTTP
Failed connects used to stay reserved so same-session retry looked like an active duplicate. hasMCPServer now means connected or in-flight. Streamable HTTP fetch always composes the connect AbortSignal so SDK 403 auth() aborts.
1 parent 2ea0fe9 commit ae995cb

4 files changed

Lines changed: 118 additions & 13 deletions

File tree

src/agent/exa-web-fetch-alias.test.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { join } from "node:path";
55
import type { ToolResult } from "@intx/types/runtime";
66
import { stringTool, type AgentTool } from "@intx/agent";
77
import { withMockedModule } from "../../tests/helpers/mock-module.js";
8-
import type { ResolvedMCPServerConfig } from "../mcp/exa.js";
8+
import { createExaMCPServerConfig, type ResolvedMCPServerConfig } from "../mcp/exa.js";
99
import type { MCPConnectOptions } from "../mcp/client.js";
1010
import { createGlobalSettingsWriter, persistGlobalHTTPMCPServer } from "../mcp/add-server.js";
1111
import { createPermissionGate } from "../permission/gate.js";
@@ -395,13 +395,13 @@ describe("built-in Exa web_fetch alias", () => {
395395
}
396396
});
397397

398-
test("failed implicit Exa remains reserved and cannot be persisted explicitly", async () => {
398+
test("failed implicit Exa is not active and retries without a second persist", async () => {
399399
connectMode = "failed";
400400
const toolset = await makeToolset();
401401
const path = join(mkdtempSync(join(tmpdir(), "corbits-mcp-failed-exa-")), "settings.json");
402402
try {
403403
await connect(toolset);
404-
expect(toolset.hasMCPServer("exa")).toBe(true);
404+
expect(toolset.hasMCPServer("exa")).toBe(false);
405405
expect(
406406
await persistGlobalHTTPMCPServer(
407407
createGlobalSettingsWriter(path),
@@ -410,8 +410,15 @@ describe("built-in Exa web_fetch alias", () => {
410410
"none",
411411
toolset.hasMCPServer,
412412
),
413-
).toEqual({ ok: false, reason: "active" });
414-
expect(await Bun.file(path).exists()).toBe(false);
413+
).toMatchObject({ ok: true, server: { name: "exa" } });
414+
415+
connectMode = "success";
416+
await toolset.connectMCPServer(createExaMCPServerConfig(), {
417+
interactiveAuth: false,
418+
onStatus: () => undefined,
419+
onToolsChanged: () => undefined,
420+
});
421+
expect(toolset.hasMCPServer("exa")).toBe(true);
415422
} finally {
416423
await toolset.dispose();
417424
}
@@ -477,7 +484,7 @@ describe("built-in Exa web_fetch alias", () => {
477484
expect(
478485
toolset.dynamicRunner.currentDefinitions().some((tool) => tool.name.includes("linear")),
479486
).toBe(false);
480-
expect(toolset.hasMCPServer("linear")).toBe(true);
487+
expect(toolset.hasMCPServer("linear")).toBe(false);
481488

482489
connectMode = "failed";
483490
const retryStates: string[] = [];
@@ -518,6 +525,29 @@ describe("built-in Exa web_fetch alias", () => {
518525

519526
expect(states.map((status) => status.state)).toEqual(["connecting", "failed"]);
520527
expect(states[1]?.error).toContain("connection exploded");
528+
expect(toolset.hasMCPServer("linear")).toBe(false);
529+
expect(await Bun.file(path).json()).toMatchObject({
530+
mcpServers: [{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" }],
531+
});
532+
expect(
533+
await persistGlobalHTTPMCPServer(
534+
createGlobalSettingsWriter(path),
535+
"linear",
536+
"https://mcp.linear.app/mcp",
537+
"none",
538+
toolset.hasMCPServer,
539+
),
540+
).toEqual({ ok: false, reason: "duplicate" });
541+
542+
connectMode = "success";
543+
const retryStates: string[] = [];
544+
await toolset.connectMCPServer(persisted.server, {
545+
interactiveAuth: true,
546+
onStatus: (status) => retryStates.push(status.state),
547+
onToolsChanged: () => undefined,
548+
});
549+
expect(retryStates).toEqual(["connecting", "connected"]);
550+
expect(toolset.hasMCPServer("linear")).toBe(true);
521551
expect(await Bun.file(path).json()).toMatchObject({
522552
mcpServers: [{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" }],
523553
});

src/agent/tools.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,9 @@ export interface AgentToolset {
209209
callbacks: MCPConnectCallbacks,
210210
signal?: AbortSignal,
211211
) => Promise<void>;
212+
// True while this name is connected or a connection is in flight — not after
213+
// a failed connect. Persist uses this to block a second add of an active name;
214+
// failed rows retry through connectMCPServer without a second persist.
212215
hasMCPServer: (name: string) => boolean;
213216
// Wire the callback the `tool_search` tool invokes to make matched tools
214217
// advertised. Set by the runner once the director + reload loop exist.
@@ -527,7 +530,6 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
527530
const dynamicRunner = createDynamicToolRunner(primaryTools, toolWatchdog);
528531
runnerHolder.current = dynamicRunner;
529532

530-
const reservedMCPServerNames = new Set(mcpServers.map((server) => server.name));
531533
const connectedClients = new Map<string, MCPClient>();
532534
const inFlightConnections = new Map<string, Promise<void>>();
533535
const mcpAbortController = new AbortController();
@@ -540,7 +542,6 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
540542
signal?: AbortSignal,
541543
): Promise<void> => {
542544
if (disposed) return Promise.resolve();
543-
reservedMCPServerNames.add(config.name);
544545
if (connectedClients.has(config.name)) return Promise.resolve();
545546
const existing = inFlightConnections.get(config.name);
546547
if (existing !== undefined) return existing;
@@ -691,7 +692,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
691692
dynamicRunner,
692693
connectMCP,
693694
connectMCPServer: connectOneMCPServer,
694-
hasMCPServer: (name) => reservedMCPServerNames.has(name),
695+
hasMCPServer: (name) => connectedClients.has(name) || inFlightConnections.has(name),
695696
setToolPromoter: (promote) => {
696697
promoter.promote = promote;
697698
},

src/mcp/client-auth-policy.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ await withMockedModule(
8282
StreamableHTTPClientTransport: class {
8383
constructor(
8484
_url: URL,
85-
private readonly options?: { requestInit?: RequestInit },
85+
private readonly options?: {
86+
requestInit?: RequestInit;
87+
fetch?: (url: string | URL, init?: RequestInit) => Promise<Response>;
88+
},
8689
) {
8790
transportOptions.push(options);
8891
lastTransportAuth = () => this.auth();
@@ -101,6 +104,9 @@ await withMockedModule(
101104
}
102105
}
103106
async auth(): Promise<void> {
107+
// SDK 403 upscoping uses raw `_fetch` with no init.signal. Hang on the
108+
// connect signal the product also installs as `fetch`, so abort still
109+
// settles this path.
104110
const signal = this.options?.requestInit?.signal;
105111
tokenRefreshSignals.push(signal);
106112
await hangUntilAbort(
@@ -149,7 +155,7 @@ await withMockedModule(
149155
}),
150156
);
151157

152-
const { connectMCPServer } = await import("./client.js");
158+
const { connectMCPServer, fetchWithConnectAbort } = await import("./client.js");
153159

154160
describe("HTTP MCP auth policy", () => {
155161
beforeEach(() => {
@@ -257,14 +263,24 @@ describe("HTTP MCP auth policy", () => {
257263
);
258264
expect(result.ok).toBe(true);
259265
if (!result.ok) return;
260-
expect(transportOptions).toEqual([{ authProvider, requestInit: { signal: abort.signal } }]);
266+
expect(transportOptions).toEqual([
267+
{ authProvider, requestInit: { signal: abort.signal }, fetch: expect.any(Function) },
268+
]);
261269

262270
const call = result.client.call("ping", {}, abort.signal);
263271
while (tokenRefreshSignals.length === 0) await Promise.resolve();
264272
expect(tokenRefreshSignals[0]).toBe(abort.signal);
265273
abort.abort(new Error("toolset disposed"));
266274
await expect(call).rejects.toThrow("toolset disposed");
267275
expect(tokenRefreshAborts).toBe(1);
276+
277+
const fetchFn = (
278+
transportOptions[0] as {
279+
fetch?: (url: string | URL, init?: RequestInit) => Promise<Response>;
280+
}
281+
).fetch;
282+
expect(fetchFn).toBeTypeOf("function");
283+
await expect(fetchFn!("https://auth.test/token")).rejects.toThrow();
268284
});
269285

270286
test("ordinary HTTP creates endpoint-scoped OAuth and passes it to transport", async () => {
@@ -281,3 +297,40 @@ describe("HTTP MCP auth policy", () => {
281297
expect(transportOptions).toEqual([{ authProvider }]);
282298
});
283299
});
300+
301+
describe("fetchWithConnectAbort", () => {
302+
test("rejects when the connect signal aborts OAuth HTTP with no init.signal", async () => {
303+
const abort = new AbortController();
304+
let seen: AbortSignal | undefined;
305+
const fetchFn = fetchWithConnectAbort(abort.signal, (_url, init) => {
306+
seen = init?.signal ?? undefined;
307+
return hangUntilAbort(init?.signal, () => undefined, "aborted").then(
308+
() => new Response(null, { status: 200 }),
309+
);
310+
});
311+
312+
const pending = fetchFn("https://auth.test/token");
313+
expect(seen).toBe(abort.signal);
314+
abort.abort(new Error("toolset disposed"));
315+
await expect(pending).rejects.toThrow("toolset disposed");
316+
});
317+
318+
test("composes connect abort with a caller request signal", async () => {
319+
const connect = new AbortController();
320+
const request = new AbortController();
321+
let seen: AbortSignal | undefined;
322+
const fetchFn = fetchWithConnectAbort(connect.signal, (_url, init) => {
323+
seen = init?.signal ?? undefined;
324+
return hangUntilAbort(init?.signal, () => undefined, "aborted").then(
325+
() => new Response(null, { status: 200 }),
326+
);
327+
});
328+
329+
const pending = fetchFn("https://auth.test/token", { signal: request.signal });
330+
expect(seen).toBeDefined();
331+
expect(seen).not.toBe(connect.signal);
332+
expect(seen).not.toBe(request.signal);
333+
connect.abort(new Error("toolset disposed"));
334+
await expect(pending).rejects.toThrow("toolset disposed");
335+
});
336+
});

src/mcp/client.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,35 @@ function isRecoverableAuthError(err: unknown): boolean {
7272
return err instanceof UnauthorizedError || err instanceof OAuthError;
7373
}
7474

75+
/**
76+
* Fetch that always attaches the connect AbortSignal. SDK 403 upscoping calls
77+
* `auth()` with raw `_fetch` (no `requestInit.signal`); `_fetchWithInit` still
78+
* uses this same function, so both paths abort when connect is cancelled.
79+
*/
80+
export function fetchWithConnectAbort(
81+
connectSignal: AbortSignal,
82+
baseFetch: (url: string | URL, init?: RequestInit) => Promise<Response> = fetch,
83+
): (url: string | URL, init?: RequestInit) => Promise<Response> {
84+
return (url, init) => {
85+
const requestSignal = init?.signal ?? undefined;
86+
const signal =
87+
requestSignal === undefined || requestSignal === connectSignal
88+
? connectSignal
89+
: AbortSignal.any([connectSignal, requestSignal]);
90+
return baseFetch(url, { ...init, signal });
91+
};
92+
}
93+
7594
function streamableHTTPTransportOptions(
7695
authProvider: CorbitsOAuthProvider | undefined,
7796
signal: AbortSignal | undefined,
7897
) {
7998
if (authProvider === undefined && signal === undefined) return undefined;
8099
return {
81100
...(authProvider === undefined ? {} : { authProvider }),
82-
...(signal === undefined ? {} : { requestInit: { signal } }),
101+
...(signal === undefined
102+
? {}
103+
: { requestInit: { signal }, fetch: fetchWithConnectAbort(signal) }),
83104
};
84105
}
85106

0 commit comments

Comments
 (0)