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
82 changes: 73 additions & 9 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,61 @@ either settings file:
.corbits/settings.json # per-repo — this project only (requires trust)
```

## Built-in Exa Preset

Corbits Code includes a preset for Exa's MCP server, and it is **on by default**.
No settings entry is required. To disable it, set `enabled` to `false`:

```jsonc
{
"mcpServers": {
"exa": { "enabled": false },
},
}
```

You may also spell the default explicitly:

```jsonc
{
"mcpServers": [{ "name": "exa", "enabled": true }],
}
```

The preset connects to `https://mcp.exa.ai/mcp`. To use a different server under
the same name, provide an ordinary transport-bearing entry instead of an
`enabled` marker:

```jsonc
{
"mcpServers": {
"exa": { "type": "http", "url": "https://example.com/custom-exa-mcp" },
},
}
```

Anonymous preset use requires no account, API key, OAuth provider, or callback
server and is rate limited by Exa; a `429` response means the anonymous limit has
been reached. Corbits Code adds no credentials or secret headers to the preset
connection. An authentication error is reported as a normal connection failure.

The built-in preset does not require project trust, even when it is injected
beside a local `.corbits/settings.json` MCP list. Local custom MCP servers still
require the normal project trust grant. A global `{ "exa": { "enabled": false } }`
disables the default even when local MCP settings omit `exa`; local settings can
explicitly re-enable the preset or override it with a custom transport-bearing
`exa` server.

The preset overlaps with the native `web_search` and `web_fetch` tools. Native
`web_search` remains lazy and unchanged. When the built-in Exa preset is active,
the canonical `web_fetch` tool calls Exa MCP's `web_fetch_exa`; the raw
`mcp__exa__web_fetch_exa` name is hidden to avoid duplicate fetch tools. If the
built-in Exa connection fails or does not advertise `web_fetch_exa`, `web_fetch`
returns an explicit Exa MCP error rather than falling back to direct fetch.
Native direct `web_fetch` is available only when the built-in Exa preset is
disabled or overridden by a custom `exa` MCP server. Other Exa MCP tools, such as
`mcp__exa__web_search_exa`, remain exposed through MCP namespacing.

**Project trust:** When `mcpServers` comes from **local** `.corbits/settings.json`,
Corbits Code does **not** spawn or connect until each server is trusted for this
project. Trust is stored as a fingerprint of `{ name, type, command, args, url }`
Expand All @@ -21,8 +76,9 @@ a separate global store (`~/.corbits/trust/path-plugins.json`) that never gates
MCP — see the trust model in `docs/PLUGINS.md`.

Global MCP from `~/.corbits/settings.json` is treated as user-configured and
does not require project trust. Local settings **replace** global MCP entirely
when present (they do not merge).
does not require project trust. Local settings replace ordinary global MCP when
present. The built-in Exa default is still injected beside local MCP unless a
global or local `exa` entry disables or overrides it as described above.

Tools from connected servers are not advertised to the model up front; they are
registered for dispatch as soon as the server connects (including later in the
Expand All @@ -35,7 +91,7 @@ A server is reached one of two ways:

- **stdio** — launched as a subprocess via `command` (+ optional `args`, `env`).
- **http** — a remote Streamable-HTTP endpoint reached by `url` and authorized
over OAuth.
when the server requires OAuth.

`type` is optional: it defaults to `stdio` when `command` is set and `http` when
only `url` is set. Set it explicitly when you want to be unambiguous.
Expand Down Expand Up @@ -109,11 +165,19 @@ needed.
OAuth tokens are written to:

```text
~/.corbits/mcp-auth/<slug>.json
~/.corbits/mcp-auth/<bounded-slug>-<endpoint-identity-sha256>.json
```

The file basename is a **slug** derived from the MCP server `name` in settings:
non-alphanumeric characters (other than `_` and `-`) become `_`, so a display name
like `my/org` persists as `my_org.json`. Tokens never appear in `settings.json`.
The settings file holds only the URL; secret material stays in the per-server auth
file. Removing that file forces re-authorization on the next connect.
Credentials are scoped to the exact server name and endpoint URL, not the display
name alone. Corbits Code parses and normalizes the URL, removes its fragment, and
hashes the unambiguous `[serverName, normalizedURL]` tuple. The full path and query
remain part of the identity, so credentials cannot cross origins, paths, or query
variants. The bounded slug prefix is derived from the server name for readability;
the raw URL never appears in the filename.

Tokens never appear in `settings.json`. The settings file holds only the URL;
secret material stays in the endpoint-scoped auth file. Legacy name-only files
such as `exa.json` are ignored and left untouched because they cannot be tied safely
to an endpoint. Existing OAuth servers therefore require one-time re-authorization
after upgrading. Removing a scoped file likewise forces
re-authorization on the next connect.
207 changes: 207 additions & 0 deletions src/agent/exa-web-fetch-alias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ToolResult } from "@intx/types/runtime";
import { stringTool, type AgentTool } from "@intx/agent";
import { withMockedModule } from "../../tests/helpers/mock-module.js";
import type { ResolvedMCPServerConfig } from "../mcp/exa.js";
import { createPermissionGate } from "../permission/gate.js";

const calls: { toolName: string; args: Record<string, unknown>; signal: AbortSignal }[] = [];
let connectConfigs: ResolvedMCPServerConfig[] = [];
let connectMode: "success" | "missing-fetch" | "failed" = "success";

await withMockedModule(
import.meta.resolve("../mcp/client.js"),
(real: typeof import("../mcp/client.js")) => ({
...real,
connectMCPServer: async (config: ResolvedMCPServerConfig) => {
connectConfigs.push(config);
if (connectMode === "failed") {
return { ok: false, serverName: config.name, error: "connection exploded" };
}
return {
ok: true,
client: {
serverName: config.name,
tools:
connectMode === "missing-fetch"
? [{ name: "web_search_exa", description: "Search", inputSchema: {} }]
: [
{ name: "web_fetch_exa", description: "Fetch", inputSchema: {} },
{ name: "web_search_exa", description: "Search", inputSchema: {} },
],
call: async (toolName: string, args: Record<string, unknown>, signal: AbortSignal) => {
calls.push({ toolName, args, signal });
return "exa fetch result";
},
close: async () => undefined,
},
};
},
}),
);

const { createAgentToolset } = await import("./tools.js");
const { resolveMcpServers } = await import("../config/index.js");
const { coreSubAgentWebTools } = await import("../subagent/run.js");

function permissionGate() {
return createPermissionGate({ approvals: [], interactive: false, skipPermissions: true });
}

async function makeToolset(mcpServers = resolveMcpServers(undefined, undefined)) {
return createAgentToolset({
cwd: mkdtempSync(join(tmpdir(), "corbits-exa-fetch-alias-")),
permissionGate: permissionGate(),
onOperatorGate: async () => ({ kind: "cancel" }),
mcpServers,
});
}

async function connect(toolset: Awaited<ReturnType<typeof createAgentToolset>>) {
await toolset.connectMCP({
interactiveAuth: false,
onStatus: () => undefined,
onToolsChanged: () => undefined,
});
}

async function runTool(
toolset: Awaited<ReturnType<typeof createAgentToolset>>,
name: string,
args: Record<string, unknown>,
signal = new AbortController().signal,
): Promise<ToolResult> {
return toolset.dynamicRunner.run({ id: `call-${name}`, name, arguments: args }, signal);
}

beforeEach(() => {
calls.length = 0;
connectConfigs = [];
connectMode = "success";
});

describe("built-in Exa web_fetch alias", () => {
test("advertises canonical web_fetch from turn 1 and hides the built-in raw fetch", async () => {
const toolset = await makeToolset();
try {
const initialNames = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
expect(initialNames).toContain("web_fetch");
expect(initialNames).not.toContain("mcp__exa__web_fetch_exa");

await connect(toolset);
const connectedNames = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
expect(connectedNames).toContain("web_fetch");
expect(connectedNames).toContain("mcp__exa__web_search_exa");
expect(connectedNames).not.toContain("mcp__exa__web_fetch_exa");
expect(connectConfigs).toHaveLength(1);
} finally {
await toolset.dispose();
}
});

test("disabled and custom Exa use ordinary native/raw behavior", async () => {
const disabled = await makeToolset(
resolveMcpServers([{ name: "exa", enabled: false }], undefined),
);
try {
expect(disabled.dynamicRunner.currentDefinitions().map((d) => d.name)).toContain("web_fetch");
await connect(disabled);
expect(connectConfigs).toHaveLength(0);
} finally {
await disabled.dispose();
}

connectConfigs = [];
const custom = await makeToolset(
resolveMcpServers(
[{ name: "exa", type: "http", url: "https://example.test/mcp" }],
undefined,
),
);
try {
await connect(custom);
const names = custom.dynamicRunner.currentDefinitions().map((d) => d.name);
expect(names).toContain("web_fetch");
expect(names).toContain("mcp__exa__web_fetch_exa");
expect(connectConfigs).toEqual([
{ name: "exa", type: "http", url: "https://example.test/mcp" },
]);
} finally {
await custom.dispose();
}
});

test("canonical web_fetch maps native args to Exa MCP fetch shape", async () => {
const toolset = await makeToolset();
try {
const controller = new AbortController();
const connecting = connect(toolset);
const result = await runTool(
toolset,
"web_fetch",
{ url: "https://example.com", format: "html", timeout: 12 },
controller.signal,
);
await connecting;

expect(result).toEqual({ callId: "call-web_fetch", content: "exa fetch result" });
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
toolName: "web_fetch_exa",
args: { urls: ["https://example.com"] },
});
expect(calls[0]?.args).not.toHaveProperty("url");
expect(calls[0]?.args).not.toHaveProperty("format");
expect(calls[0]?.args).not.toHaveProperty("timeout");
expect(calls[0]?.signal).toBeInstanceOf(AbortSignal);
} finally {
await toolset.dispose();
}
});

test("canonical web_fetch returns explicit Exa MCP errors without native fallback", async () => {
connectMode = "missing-fetch";
const toolset = await makeToolset();
try {
await connect(toolset);
const result = await runTool(toolset, "web_fetch", { url: "https://example.com" });
expect(result.isError).toBe(true);
expect(result.content).toContain("Exa MCP");
expect(result.content).toContain("web_fetch_exa");
expect(calls).toHaveLength(0);
} finally {
await toolset.dispose();
}

connectMode = "failed";
const failed = await makeToolset();
try {
await connect(failed);
const result = await runTool(failed, "web_fetch", { url: "https://example.com" });
expect(result.isError).toBe(true);
expect(result.content).toContain("Exa MCP");
expect(result.content).toContain("connection exploded");
expect(calls).toHaveLength(0);
} finally {
await failed.dispose();
}
});

test("child assembly keeps inherited canonical web_fetch and avoids duplicate native fetch", () => {
const inherited: AgentTool[] = [
stringTool({
definition: { name: "web_fetch", description: "Inherited Exa fetch", inputSchema: {} },
handler: async () => "inherited",
}),
];
const names = [...coreSubAgentWebTools(inherited), ...inherited].map(
(tool) => tool.definition.name,
);
expect(names.filter((name) => name === "web_fetch")).toHaveLength(1);
expect(names).toContain("web_fetch");
expect(names).toContain("web_search");
});
});
Loading
Loading