Skip to content

Commit 13cde7b

Browse files
committed
Add MCP servers from the MCP surface
1 parent 686f9ea commit 13cde7b

17 files changed

Lines changed: 1386 additions & 193 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: 218 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,32 @@ 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 connectMode: "success" | "missing-fetch" | "failed" | "auth" | "deferred" = "success";
1419

1520
await withMockedModule(
1621
import.meta.resolve("../mcp/client.js"),
1722
(real: typeof import("../mcp/client.js")) => ({
1823
...real,
19-
connectMCPServer: async (config: ResolvedMCPServerConfig) => {
24+
connectMCPServer: async (config: ResolvedMCPServerConfig, options: MCPConnectOptions = {}) => {
2025
connectConfigs.push(config);
26+
connectOptions.push(options);
27+
if (connectMode === "auth") {
28+
options.onAuthURL?.(config.name, "https://auth.test/authorize");
29+
}
30+
if (connectMode === "deferred") {
31+
await new Promise<void>((resolve) => {
32+
releaseDeferredConnect = resolve;
33+
});
34+
}
2135
if (connectMode === "failed") {
2236
return { ok: false, serverName: config.name, error: "connection exploded" };
2337
}
@@ -36,7 +50,9 @@ await withMockedModule(
3650
calls.push({ toolName, args, signal });
3751
return "exa fetch result";
3852
},
39-
close: async () => undefined,
53+
close: async () => {
54+
closedClients.push(config.name);
55+
},
4056
},
4157
};
4258
},
@@ -51,10 +67,13 @@ function permissionGate() {
5167
return createPermissionGate({ approvals: [], interactive: false, skipPermissions: true });
5268
}
5369

54-
async function makeToolset(mcpServers = resolveMcpServers(undefined, undefined)) {
70+
async function makeToolset(
71+
mcpServers = resolveMcpServers(undefined, undefined),
72+
gate = permissionGate(),
73+
) {
5574
return createAgentToolset({
5675
cwd: mkdtempSync(join(tmpdir(), "corbits-exa-fetch-alias-")),
57-
permissionGate: permissionGate(),
76+
permissionGate: gate,
5877
onOperatorGate: async () => ({ kind: "cancel" }),
5978
mcpServers,
6079
});
@@ -79,7 +98,10 @@ async function runTool(
7998

8099
beforeEach(() => {
81100
calls.length = 0;
101+
closedClients.length = 0;
82102
connectConfigs = [];
103+
connectOptions = [];
104+
releaseDeferredConnect = undefined;
83105
connectMode = "success";
84106
});
85107

@@ -206,6 +228,197 @@ describe("built-in Exa web_fetch alias", () => {
206228
}
207229
});
208230

231+
test("single-server connection deduplicates and hands OAuth status through", async () => {
232+
connectMode = "auth";
233+
const toolset = await makeToolset(
234+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
235+
);
236+
const states: { state: string; url?: string }[] = [];
237+
const callbacks = {
238+
interactiveAuth: true,
239+
onStatus: (status: { state: string; url?: string }) => states.push(status),
240+
onToolsChanged: () => undefined,
241+
};
242+
const server = { name: "linear", type: "http" as const, url: "https://mcp.linear.app/mcp" };
243+
try {
244+
await Promise.all([
245+
toolset.connectMCPServer(server, callbacks),
246+
toolset.connectMCPServer(server, callbacks),
247+
]);
248+
await toolset.connectMCPServer(server, callbacks);
249+
250+
expect(connectConfigs).toEqual([server]);
251+
expect(states.map((status) => status.state)).toEqual([
252+
"connecting",
253+
"needs-auth",
254+
"connected",
255+
]);
256+
expect(states[1]?.url).toBe("https://auth.test/authorize");
257+
expect(connectOptions[0]?.onAuthURL).toBeDefined();
258+
} finally {
259+
await toolset.dispose();
260+
}
261+
});
262+
263+
test("dispose invalidates an in-flight connection and closes its late client", async () => {
264+
connectMode = "deferred";
265+
const toolset = await makeToolset(
266+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
267+
);
268+
const states: string[] = [];
269+
const connection = toolset.connectMCPServer(
270+
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" },
271+
{
272+
interactiveAuth: true,
273+
onStatus: (status) => states.push(status.state),
274+
onToolsChanged: () => undefined,
275+
},
276+
);
277+
await Promise.resolve();
278+
279+
let disposed = false;
280+
const disposal = toolset.dispose().then(() => {
281+
disposed = true;
282+
});
283+
await Promise.resolve();
284+
expect(disposed).toBe(false);
285+
releaseDeferredConnect?.();
286+
await Promise.all([connection, disposal]);
287+
288+
expect(states).toEqual(["connecting"]);
289+
expect(closedClients).toEqual(["linear"]);
290+
expect(
291+
toolset.dynamicRunner.currentDefinitions().some((tool) => tool.name.includes("linear")),
292+
).toBe(false);
293+
});
294+
295+
test("rejects connected and in-flight implicit Exa names before persistence", async () => {
296+
const connected = await makeToolset();
297+
const connectedPath = join(mkdtempSync(join(tmpdir(), "corbits-mcp-active-")), "settings.json");
298+
try {
299+
await connect(connected);
300+
expect(connected.hasMCPServer("exa")).toBe(true);
301+
expect(
302+
await persistGlobalHTTPMCPServer(
303+
createGlobalSettingsWriter(connectedPath),
304+
"exa",
305+
"https://custom.test/mcp",
306+
"none",
307+
connected.hasMCPServer,
308+
),
309+
).toEqual({ ok: false, reason: "active" });
310+
expect(await Bun.file(connectedPath).exists()).toBe(false);
311+
} finally {
312+
await connected.dispose();
313+
}
314+
315+
connectMode = "deferred";
316+
const inFlight = await makeToolset();
317+
const inFlightPath = join(mkdtempSync(join(tmpdir(), "corbits-mcp-active-")), "settings.json");
318+
const startup = connect(inFlight);
319+
while (releaseDeferredConnect === undefined) await Promise.resolve();
320+
try {
321+
expect(inFlight.hasMCPServer("exa")).toBe(true);
322+
expect(
323+
await persistGlobalHTTPMCPServer(
324+
createGlobalSettingsWriter(inFlightPath),
325+
"exa",
326+
"https://custom.test/mcp",
327+
"none",
328+
inFlight.hasMCPServer,
329+
),
330+
).toEqual({ ok: false, reason: "active" });
331+
expect(await Bun.file(inFlightPath).exists()).toBe(false);
332+
} finally {
333+
releaseDeferredConnect?.();
334+
await startup;
335+
await inFlight.dispose();
336+
}
337+
});
338+
339+
test("failed implicit Exa remains reserved and cannot be persisted explicitly", async () => {
340+
connectMode = "failed";
341+
const toolset = await makeToolset();
342+
const path = join(mkdtempSync(join(tmpdir(), "corbits-mcp-failed-exa-")), "settings.json");
343+
try {
344+
await connect(toolset);
345+
expect(toolset.hasMCPServer("exa")).toBe(true);
346+
expect(
347+
await persistGlobalHTTPMCPServer(
348+
createGlobalSettingsWriter(path),
349+
"exa",
350+
"https://custom.test/mcp",
351+
"none",
352+
toolset.hasMCPServer,
353+
),
354+
).toEqual({ ok: false, reason: "active" });
355+
expect(await Bun.file(path).exists()).toBe(false);
356+
} finally {
357+
await toolset.dispose();
358+
}
359+
});
360+
361+
test("single-server registration failure closes the client and reports failed", async () => {
362+
const gate = permissionGate();
363+
gate.registerMcpClient = () => {
364+
throw new Error("registration exploded");
365+
};
366+
const toolset = await makeToolset(
367+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
368+
gate,
369+
);
370+
const states: { state: string; error?: string }[] = [];
371+
try {
372+
await toolset.connectMCPServer(
373+
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" },
374+
{
375+
interactiveAuth: true,
376+
onStatus: (status) => states.push(status),
377+
onToolsChanged: () => undefined,
378+
},
379+
);
380+
381+
expect(states.map((status) => status.state)).toEqual(["connecting", "failed"]);
382+
expect(states[1]?.error).toContain("registration exploded");
383+
expect(closedClients).toEqual(["linear"]);
384+
} finally {
385+
await toolset.dispose();
386+
}
387+
});
388+
389+
test("connection failure leaves the late-added server persisted and reports failed", async () => {
390+
connectMode = "failed";
391+
const dir = mkdtempSync(join(tmpdir(), "corbits-mcp-failure-"));
392+
const path = join(dir, "settings.json");
393+
const persisted = await persistGlobalHTTPMCPServer(
394+
createGlobalSettingsWriter(path),
395+
"linear",
396+
"https://mcp.linear.app/mcp",
397+
);
398+
expect(persisted.ok).toBe(true);
399+
if (!persisted.ok) return;
400+
401+
const toolset = await makeToolset(
402+
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
403+
);
404+
const states: { state: string; error?: string }[] = [];
405+
try {
406+
await toolset.connectMCPServer(persisted.server, {
407+
interactiveAuth: true,
408+
onStatus: (status) => states.push(status),
409+
onToolsChanged: () => undefined,
410+
});
411+
412+
expect(states.map((status) => status.state)).toEqual(["connecting", "failed"]);
413+
expect(states[1]?.error).toContain("connection exploded");
414+
expect(await Bun.file(path).json()).toMatchObject({
415+
mcpServers: [{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp" }],
416+
});
417+
} finally {
418+
await toolset.dispose();
419+
}
420+
});
421+
209422
test("child assembly keeps inherited canonical web_fetch and avoids duplicate native fetch", () => {
210423
const inherited: AgentTool[] = [
211424
stringTool({

0 commit comments

Comments
 (0)