Skip to content

Commit be89006

Browse files
committed
Add MCP servers from the MCP surface
1 parent 3ec14d4 commit be89006

19 files changed

Lines changed: 2004 additions & 226 deletions

docs/MCP.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ registered for dispatch as soon as the server connects (including later in the
8686
same turn) and surfaced on demand through dynamic tool discovery
8787
(`tool_search`).
8888

89+
In the TUI, `/mcp` and `/mcps` open the same live server surface. Press **Alt+A**
90+
to add a named absolute HTTP(S) endpoint to global settings and connect it in the
91+
current session. Names may contain letters, numbers, single underscores, and
92+
hyphens; the `__` tool-namespace delimiter is reserved. The add is unavailable
93+
while a local `.corbits/settings.json` `mcpServers` list shadows global MCP
94+
settings; remove that local list and restart
95+
before adding globally. A connection failure does not remove the saved server.
96+
8997
## Server Kinds
9098

9199
A server is reached one of two ways:

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

Lines changed: 325 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,52 @@ import type { ToolResult } from "@intx/types/runtime";
66
import { stringTool, type AgentTool } from "@intx/agent";
77
import { withMockedModule } from "../../tests/helpers/mock-module.js";
88
import type { ResolvedMCPServerConfig } from "../mcp/exa.js";
9+
import type { MCPConnectOptions } from "../mcp/client.js";
10+
import { createGlobalSettingsWriter, persistGlobalHTTPMCPServer } from "../mcp/add-server.js";
911
import { createPermissionGate } from "../permission/gate.js";
1012

1113
const calls: { toolName: string; args: Record<string, unknown>; signal: AbortSignal }[] = [];
14+
const closedClients: string[] = [];
1215
let connectConfigs: ResolvedMCPServerConfig[] = [];
13-
let connectMode: "success" | "missing-fetch" | "failed" = "success";
16+
let connectOptions: MCPConnectOptions[] = [];
17+
let releaseDeferredConnect: (() => void) | undefined;
18+
let authWaitAborts = 0;
19+
let authResourceCloses = 0;
20+
let blockInteractiveAuth = false;
21+
let connectMode: "success" | "missing-fetch" | "failed" | "rejected" | "auth" | "deferred" =
22+
"success";
1423

1524
await withMockedModule(
1625
import.meta.resolve("../mcp/client.js"),
1726
(real: typeof import("../mcp/client.js")) => ({
1827
...real,
19-
connectMCPServer: async (config: ResolvedMCPServerConfig) => {
28+
connectMCPServer: async (config: ResolvedMCPServerConfig, options: MCPConnectOptions = {}) => {
2029
connectConfigs.push(config);
30+
connectOptions.push(options);
31+
if (connectMode === "auth" || blockInteractiveAuth) {
32+
options.onAuthURL?.(config.name, "https://auth.test/authorize");
33+
}
34+
if (blockInteractiveAuth) {
35+
await new Promise<void>((resolve) => {
36+
const onAbort = (): void => {
37+
authWaitAborts += 1;
38+
authResourceCloses += 1;
39+
resolve();
40+
};
41+
if (options.signal?.aborted === true) {
42+
onAbort();
43+
} else {
44+
options.signal?.addEventListener("abort", onAbort, { once: true });
45+
}
46+
});
47+
return { ok: false, serverName: config.name, error: "authorization aborted" };
48+
}
49+
if (connectMode === "deferred") {
50+
await new Promise<void>((resolve) => {
51+
releaseDeferredConnect = resolve;
52+
});
53+
}
54+
if (connectMode === "rejected") throw new Error("transport setup exploded");
2155
if (connectMode === "failed") {
2256
return { ok: false, serverName: config.name, error: "connection exploded" };
2357
}
@@ -36,7 +70,9 @@ await withMockedModule(
3670
calls.push({ toolName, args, signal });
3771
return "exa fetch result";
3872
},
39-
close: async () => undefined,
73+
close: async () => {
74+
closedClients.push(config.name);
75+
},
4076
},
4177
};
4278
},
@@ -51,10 +87,13 @@ function permissionGate() {
5187
return createPermissionGate({ approvals: [], interactive: false, skipPermissions: true });
5288
}
5389

54-
async function makeToolset(mcpServers = resolveMcpServers(undefined, undefined)) {
90+
async function makeToolset(
91+
mcpServers = resolveMcpServers(undefined, undefined),
92+
gate = permissionGate(),
93+
) {
5594
return createAgentToolset({
5695
cwd: mkdtempSync(join(tmpdir(), "corbits-exa-fetch-alias-")),
57-
permissionGate: permissionGate(),
96+
permissionGate: gate,
5897
onOperatorGate: async () => ({ kind: "cancel" }),
5998
mcpServers,
6099
});
@@ -79,7 +118,13 @@ async function runTool(
79118

80119
beforeEach(() => {
81120
calls.length = 0;
121+
closedClients.length = 0;
82122
connectConfigs = [];
123+
connectOptions = [];
124+
releaseDeferredConnect = undefined;
125+
authWaitAborts = 0;
126+
authResourceCloses = 0;
127+
blockInteractiveAuth = false;
83128
connectMode = "success";
84129
});
85130

@@ -206,6 +251,281 @@ describe("built-in Exa web_fetch alias", () => {
206251
}
207252
});
208253

254+
test("single-server connection deduplicates and hands OAuth status through", async () => {
255+
connectMode = "auth";
256+
const toolset = await makeToolset(
257+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
258+
);
259+
const states: { state: string; url?: string }[] = [];
260+
const callbacks = {
261+
interactiveAuth: true,
262+
onStatus: (status: { state: string; url?: string }) => states.push(status),
263+
onToolsChanged: () => undefined,
264+
};
265+
const server = { name: "linear", type: "http" as const, url: "https://mcp.linear.app/mcp" };
266+
try {
267+
await Promise.all([
268+
toolset.connectMCPServer(server, callbacks),
269+
toolset.connectMCPServer(server, callbacks),
270+
]);
271+
await toolset.connectMCPServer(server, callbacks);
272+
273+
expect(connectConfigs).toEqual([server]);
274+
expect(states.map((status) => status.state)).toEqual([
275+
"connecting",
276+
"needs-auth",
277+
"connected",
278+
]);
279+
expect(states[1]?.url).toBe("https://auth.test/authorize");
280+
expect(connectOptions[0]?.onAuthURL).toBeDefined();
281+
} finally {
282+
await toolset.dispose();
283+
}
284+
});
285+
286+
test("dispose invalidates an in-flight connection and closes its late client", async () => {
287+
connectMode = "deferred";
288+
const toolset = await makeToolset(
289+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
290+
);
291+
const states: string[] = [];
292+
const connection = toolset.connectMCPServer(
293+
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" },
294+
{
295+
interactiveAuth: true,
296+
onStatus: (status) => states.push(status.state),
297+
onToolsChanged: () => undefined,
298+
},
299+
);
300+
await Promise.resolve();
301+
302+
let disposed = false;
303+
const disposal = toolset.dispose().then(() => {
304+
disposed = true;
305+
});
306+
await Promise.resolve();
307+
expect(disposed).toBe(false);
308+
releaseDeferredConnect?.();
309+
await Promise.all([connection, disposal]);
310+
311+
expect(states).toEqual(["connecting"]);
312+
expect(closedClients).toEqual(["linear"]);
313+
expect(
314+
toolset.dynamicRunner.currentDefinitions().some((tool) => tool.name.includes("linear")),
315+
).toBe(false);
316+
});
317+
318+
test("dispose aborts blocked interactive auth and closes its resources", async () => {
319+
blockInteractiveAuth = true;
320+
const toolset = await makeToolset(
321+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
322+
);
323+
const callerAbort = new AbortController();
324+
const states: string[] = [];
325+
const connection = toolset.connectMCPServer(
326+
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" },
327+
{
328+
interactiveAuth: true,
329+
onStatus: (status) => states.push(status.state),
330+
onToolsChanged: () => undefined,
331+
},
332+
callerAbort.signal,
333+
);
334+
while (connectOptions.length === 0) await Promise.resolve();
335+
336+
const ownedSignal = connectOptions[0]?.signal;
337+
expect(ownedSignal).toBeDefined();
338+
expect(ownedSignal).not.toBe(callerAbort.signal);
339+
const disposal = toolset.dispose();
340+
expect(toolset.dispose()).toBe(disposal);
341+
await Promise.resolve();
342+
expect(ownedSignal?.aborted).toBe(true);
343+
expect(callerAbort.signal.aborted).toBe(false);
344+
await Promise.all([connection, disposal]);
345+
346+
expect(authWaitAborts).toBe(1);
347+
expect(authResourceCloses).toBe(1);
348+
expect(states).toEqual(["connecting", "needs-auth"]);
349+
expect(
350+
toolset.dynamicRunner.currentDefinitions().some((tool) => tool.name.includes("linear")),
351+
).toBe(false);
352+
});
353+
354+
test("rejects connected and in-flight implicit Exa names before persistence", async () => {
355+
const connected = await makeToolset();
356+
const connectedPath = join(mkdtempSync(join(tmpdir(), "corbits-mcp-active-")), "settings.json");
357+
try {
358+
await connect(connected);
359+
expect(connected.hasMCPServer("exa")).toBe(true);
360+
expect(
361+
await persistGlobalHTTPMCPServer(
362+
createGlobalSettingsWriter(connectedPath),
363+
"exa",
364+
"https://custom.test/mcp",
365+
"none",
366+
connected.hasMCPServer,
367+
),
368+
).toEqual({ ok: false, reason: "active" });
369+
expect(await Bun.file(connectedPath).exists()).toBe(false);
370+
} finally {
371+
await connected.dispose();
372+
}
373+
374+
connectMode = "deferred";
375+
const inFlight = await makeToolset();
376+
const inFlightPath = join(mkdtempSync(join(tmpdir(), "corbits-mcp-active-")), "settings.json");
377+
const startup = connect(inFlight);
378+
while (releaseDeferredConnect === undefined) await Promise.resolve();
379+
try {
380+
expect(inFlight.hasMCPServer("exa")).toBe(true);
381+
expect(
382+
await persistGlobalHTTPMCPServer(
383+
createGlobalSettingsWriter(inFlightPath),
384+
"exa",
385+
"https://custom.test/mcp",
386+
"none",
387+
inFlight.hasMCPServer,
388+
),
389+
).toEqual({ ok: false, reason: "active" });
390+
expect(await Bun.file(inFlightPath).exists()).toBe(false);
391+
} finally {
392+
releaseDeferredConnect?.();
393+
await startup;
394+
await inFlight.dispose();
395+
}
396+
});
397+
398+
test("failed implicit Exa remains reserved and cannot be persisted explicitly", async () => {
399+
connectMode = "failed";
400+
const toolset = await makeToolset();
401+
const path = join(mkdtempSync(join(tmpdir(), "corbits-mcp-failed-exa-")), "settings.json");
402+
try {
403+
await connect(toolset);
404+
expect(toolset.hasMCPServer("exa")).toBe(true);
405+
expect(
406+
await persistGlobalHTTPMCPServer(
407+
createGlobalSettingsWriter(path),
408+
"exa",
409+
"https://custom.test/mcp",
410+
"none",
411+
toolset.hasMCPServer,
412+
),
413+
).toEqual({ ok: false, reason: "active" });
414+
expect(await Bun.file(path).exists()).toBe(false);
415+
} finally {
416+
await toolset.dispose();
417+
}
418+
});
419+
420+
test("single-server registration failure closes the client and reports failed", async () => {
421+
const gate = permissionGate();
422+
gate.registerMcpClient = () => {
423+
throw new Error("registration exploded");
424+
};
425+
const toolset = await makeToolset(
426+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
427+
gate,
428+
);
429+
const states: { state: string; error?: string }[] = [];
430+
try {
431+
await toolset.connectMCPServer(
432+
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" },
433+
{
434+
interactiveAuth: true,
435+
onStatus: (status) => states.push(status),
436+
onToolsChanged: () => undefined,
437+
},
438+
);
439+
440+
expect(states.map((status) => status.state)).toEqual(["connecting", "failed"]);
441+
expect(states[1]?.error).toContain("registration exploded");
442+
expect(closedClients).toEqual(["linear"]);
443+
} finally {
444+
await toolset.dispose();
445+
}
446+
});
447+
448+
test("rejected single-server connection reports failed without registration or client leaks", async () => {
449+
connectMode = "rejected";
450+
const gate = permissionGate();
451+
let registrations = 0;
452+
let unregistrations = 0;
453+
gate.registerMcpClient = () => {
454+
registrations += 1;
455+
};
456+
gate.unregisterMcpServer = () => {
457+
unregistrations += 1;
458+
};
459+
const toolset = await makeToolset(
460+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
461+
gate,
462+
);
463+
const server = { name: "linear", type: "http" as const, url: "https://mcp.linear.app/mcp" };
464+
const states: { state: string; error?: string }[] = [];
465+
try {
466+
await toolset.connectMCPServer(server, {
467+
interactiveAuth: true,
468+
onStatus: (status) => states.push(status),
469+
onToolsChanged: () => undefined,
470+
});
471+
472+
expect(states.map((status) => status.state)).toEqual(["connecting", "failed"]);
473+
expect(states[1]?.error).toContain("transport setup exploded");
474+
expect(registrations).toBe(0);
475+
expect(unregistrations).toBe(0);
476+
expect(closedClients).toEqual([]);
477+
expect(
478+
toolset.dynamicRunner.currentDefinitions().some((tool) => tool.name.includes("linear")),
479+
).toBe(false);
480+
expect(toolset.hasMCPServer("linear")).toBe(true);
481+
482+
connectMode = "failed";
483+
const retryStates: string[] = [];
484+
await toolset.connectMCPServer(server, {
485+
interactiveAuth: true,
486+
onStatus: (status) => retryStates.push(status.state),
487+
onToolsChanged: () => undefined,
488+
});
489+
expect(retryStates).toEqual(["connecting", "failed"]);
490+
expect(connectConfigs).toEqual([server, server]);
491+
} finally {
492+
await toolset.dispose();
493+
}
494+
});
495+
496+
test("connection failure leaves the late-added server persisted and reports failed", async () => {
497+
connectMode = "failed";
498+
const dir = mkdtempSync(join(tmpdir(), "corbits-mcp-failure-"));
499+
const path = join(dir, "settings.json");
500+
const persisted = await persistGlobalHTTPMCPServer(
501+
createGlobalSettingsWriter(path),
502+
"linear",
503+
"https://mcp.linear.app/mcp",
504+
);
505+
expect(persisted.ok).toBe(true);
506+
if (!persisted.ok) return;
507+
508+
const toolset = await makeToolset(
509+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
510+
);
511+
const states: { state: string; error?: string }[] = [];
512+
try {
513+
await toolset.connectMCPServer(persisted.server, {
514+
interactiveAuth: true,
515+
onStatus: (status) => states.push(status),
516+
onToolsChanged: () => undefined,
517+
});
518+
519+
expect(states.map((status) => status.state)).toEqual(["connecting", "failed"]);
520+
expect(states[1]?.error).toContain("connection exploded");
521+
expect(await Bun.file(path).json()).toMatchObject({
522+
mcpServers: [{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" }],
523+
});
524+
} finally {
525+
await toolset.dispose();
526+
}
527+
});
528+
209529
test("child assembly keeps inherited canonical web_fetch and avoids duplicate native fetch", () => {
210530
const inherited: AgentTool[] = [
211531
stringTool({

0 commit comments

Comments
 (0)