diff --git a/docs/MCP.md b/docs/MCP.md index a4b032b7a..ac2f4bb63 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -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 }` @@ -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 @@ -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. @@ -109,11 +165,19 @@ needed. OAuth tokens are written to: ```text -~/.corbits/mcp-auth/.json +~/.corbits/mcp-auth/-.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. diff --git a/src/agent/exa-web-fetch-alias.test.ts b/src/agent/exa-web-fetch-alias.test.ts new file mode 100644 index 000000000..3ce24e45f --- /dev/null +++ b/src/agent/exa-web-fetch-alias.test.ts @@ -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; 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, 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>) { + await toolset.connectMCP({ + interactiveAuth: false, + onStatus: () => undefined, + onToolsChanged: () => undefined, + }); +} + +async function runTool( + toolset: Awaited>, + name: string, + args: Record, + signal = new AbortController().signal, +): Promise { + 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"); + }); +}); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 894646190..a5df40c54 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -22,7 +22,8 @@ import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js"; import { createLazyBlobReader } from "./lazy-blob-reader.js"; import type { BlobReader } from "@intx/types/runtime"; import type { SpillBlobWriter } from "../plugins/result-truncation-plugin.js"; -import { connectMCPServer, type MCPClient } from "../mcp/client.js"; +import { connectMCPServer, type MCPClient, type MCPConnectResult } from "../mcp/client.js"; +import { createExaMCPServerConfig, isBuiltinExaMCPServer } from "../mcp/exa.js"; import { mcpClientToAgentTools } from "../mcp/plugin.js"; import { createDynamicToolRunner, type DynamicToolRunner } from "../tui/dynamic-tool-runner.js"; import type { MCPServerConfig, Settings } from "../config/settings.js"; @@ -54,7 +55,7 @@ import { } from "../subagent/lifecycle-tools.js"; import { parseManageTasksArgs } from "./tasks.js"; import { createListDirTool } from "../util/list-dir.js"; -import { createWebFetchTool } from "../tools/web-fetch.js"; +import { createExaMCPWebFetchTool, createWebFetchTool } from "../tools/web-fetch.js"; import { createWebSearchTool, disposeWebSearchClients } from "../tools/web-search.js"; import { createUseSkillTool } from "./use-skill.js"; import { createToolIndex, createToolSearchTool } from "./tool-search.js"; @@ -209,7 +210,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise void) | undefined; + const builtinExaConnection = builtinExaEnabled + ? new Promise((resolve) => { + resolveBuiltinExaConnection = resolve; + }) + : undefined; + + const waitForBuiltinExaConnection = async (signal: AbortSignal): Promise => { + if (builtinExaConnection === undefined) { + return { ok: false, serverName: "exa", error: "built-in Exa MCP is not enabled" }; + } + if (signal.aborted) { + return { + ok: false, + serverName: "exa", + error: "aborted while waiting for Exa MCP connection", + }; + } + return new Promise((resolve) => { + const onAbort = (): void => { + resolve({ + ok: false, + serverName: "exa", + error: "aborted while waiting for Exa MCP connection", + }); + }; + signal.addEventListener("abort", onAbort, { once: true }); + builtinExaConnection.then((result) => { + signal.removeEventListener("abort", onAbort); + resolve(result); + }); + }); + }; const inheritedMcpTools: AgentTool[] = []; + if (builtinExaEnabled) { + inheritedMcpTools.push(createExaMCPWebFetchTool({ connect: waitForBuiltinExaConnection })); + } const posixTools = createPosixTools({ cwd, @@ -374,7 +412,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise permissionGate.getSkipPermissions(), }), createUseSkillTool(cwd, skillDirs, args.telemetry), - createWebFetchTool(), + builtinExaEnabled + ? createExaMCPWebFetchTool({ connect: waitForBuiltinExaConnection }) + : createWebFetchTool(), createWebSearchTool(), ...orchestratorTools, stringTool({ @@ -491,7 +531,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { callbacks.onStatus({ name: config.name, state: "connecting" }); - const result = await connectMCPServer(config, { + const connection = connectMCPServer(config, { stderr: "ignore", ...(callbacks.interactiveAuth ? { @@ -513,6 +553,10 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise resolveBuiltinExaConnection?.(result)); + } + const result = await connection; if (!result.ok) { callbacks.onStatus({ name: config.name, state: "failed", error: result.error }); return; @@ -522,6 +566,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { +async function writeGlobalSettings(cwd: string, mcpServers?: unknown): Promise { const path = join(cwd, "global.json"); await writeFile( path, @@ -55,6 +60,7 @@ async function writeGlobalSettings(cwd: string): Promise { models: ["accounts/fireworks/routers/kimi-k2p6-turbo"], }, }, + ...(mcpServers !== undefined ? { mcpServers } : {}), }), ); return path; @@ -87,6 +93,116 @@ describe("loadConfig", () => { } }); + test("injects the built-in Exa MCP server when no list disables or overrides it", async () => { + expect(resolveMcpServers(undefined, undefined)).toEqual([BUILTIN_EXA_MCP]); + + const cwd = await emptyCwd(); + try { + const globalPath = await writeGlobalSettings(cwd); + const config = await loadConfig(["--cwd", cwd, "hello"], { globalSettingsPath: globalPath }); + assertConfigured(config); + expect(config.mcpServers).toEqual([BUILTIN_EXA_MCP]); + expect(config.mcpServersSource).toBe("none"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("expands enabled Exa preset and honors explicit disable", () => { + expect(resolveMcpServers([{ name: "exa", enabled: true }], undefined)).toEqual([ + BUILTIN_EXA_MCP, + ]); + expect(resolveMcpServers([{ name: "exa", enabled: false }], undefined)).toEqual([]); + }); + + test("keeps global and local MCP source at list level", async () => { + const cwd = await emptyCwd(); + try { + const globalPath = await writeGlobalSettings(cwd, { exa: { enabled: true } }); + const globalConfig = await loadConfig(["--cwd", cwd, "hello"], { + globalSettingsPath: globalPath, + }); + assertConfigured(globalConfig); + expect(globalConfig.mcpServers).toEqual([BUILTIN_EXA_MCP]); + expect(globalConfig.mcpServersSource).toBe("global"); + + await writeGlobalSettings(cwd, { exa: { enabled: false } }); + const disabledConfig = await loadConfig(["--cwd", cwd, "hello"], { + globalSettingsPath: globalPath, + }); + assertConfigured(disabledConfig); + expect(disabledConfig.mcpServers).toEqual([]); + expect(disabledConfig.mcpServersSource).toBe("global"); + + await writeGlobalSettings(cwd, { exa: { enabled: true } }); + await mkdir(join(cwd, ".corbits"), { recursive: true }); + await writeFile( + join(cwd, ".corbits", "settings.json"), + JSON.stringify({ mcpServers: { local: { command: "local-mcp" } } }), + ); + const localConfig = await loadConfig(["--cwd", cwd, "hello"], { + globalSettingsPath: globalPath, + }); + assertConfigured(localConfig); + expect(localConfig.mcpServers).toEqual([ + BUILTIN_EXA_MCP, + { name: "local", command: "local-mcp" }, + ]); + expect(localConfig.mcpServersSource).toBe("local"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("preserves custom Exa and lets local omission inherit global disable", () => { + expect( + resolveMcpServers( + [{ name: "exa", type: "http", url: "https://example.test/mcp" }], + undefined, + ), + ).toEqual([{ name: "exa", type: "http", url: "https://example.test/mcp" }]); + expect( + resolveMcpServers( + [{ name: "exa", type: "http", url: "https://example.test/mcp" }], + [{ name: "local", command: "local-mcp" }], + ), + ).toEqual([{ name: "local", command: "local-mcp" }]); + expect( + resolveMcpServers( + [{ name: "exa", enabled: false }], + [{ name: "local", command: "local-mcp" }], + ), + ).toEqual([{ name: "local", command: "local-mcp" }]); + expect( + resolveMcpServers([{ name: "exa", enabled: false }], [{ name: "exa", enabled: true }]), + ).toEqual([BUILTIN_EXA_MCP]); + expect( + resolveMcpServers( + [{ name: "exa", type: "http", url: "https://example.test/mcp" }], + [{ name: "exa", enabled: false }], + ), + ).toEqual([]); + expect( + resolveMcpServers( + [{ name: "exa", enabled: false }], + [{ name: "exa", type: "http", url: "https://local.example.test/mcp" }], + ), + ).toEqual([{ name: "exa", type: "http", url: "https://local.example.test/mcp" }]); + }); + + test("local custom MCP requires trust while built-in Exa bypasses project trust", async () => { + const servers = resolveMcpServers(undefined, [{ name: "local", command: "local-mcp" }]); + + expect(servers).toEqual([BUILTIN_EXA_MCP, { name: "local", command: "local-mcp" }]); + await expect( + filterMcpServersForConnect(servers, { + source: "local", + cwd: "/repo/without-trust-grant", + store: { trustedPluginPaths: [], trustedMcpFingerprints: [] }, + }), + ).resolves.toEqual([BUILTIN_EXA_MCP]); + }); + test("throws when no provider can be resolved (allowUnconfigured false)", async () => { const cwd = await emptyCwd(); try { diff --git a/src/config/index.ts b/src/config/index.ts index d64fdf0d2..3a44057be 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -56,11 +56,16 @@ import { localSettingsPath, normalizeOpenAICompatibleBaseURL, resolveProvider, - type MCPServerConfig, + type MCPServerSettingsEntry, type ResolvedProvider, type Settings, type ProviderSettings, } from "./settings.js"; +import { + EXA_MCP_SERVER_NAME, + createExaMCPServerConfig, + type ResolvedMCPServerConfig, +} from "../mcp/exa.js"; import { resolveProfile } from "./profiles.js"; // The per-call token ceiling for the inference source. Lives here so agent @@ -75,6 +80,46 @@ export const SOURCE_MAX_TOKENS = 16384; // keyless servers ignore it entirely. export const KEYLESS_API_KEY = "keyless"; +function hasExaEntry(servers: MCPServerSettingsEntry[] | undefined): boolean { + return servers?.some((server) => server.name === EXA_MCP_SERVER_NAME) === true; +} + +function globalExaSuppressesBuiltin(servers: MCPServerSettingsEntry[] | undefined): boolean { + return ( + servers?.some( + (server) => + server.name === EXA_MCP_SERVER_NAME && (!("enabled" in server) || !server.enabled), + ) === true + ); +} + +function expandMcpServers(servers: MCPServerSettingsEntry[]): ResolvedMCPServerConfig[] { + return servers.flatMap((server) => { + if (!("enabled" in server)) return [server]; + return server.enabled ? [createExaMCPServerConfig()] : []; + }); +} + +export function resolveMcpServers( + globalServers: MCPServerSettingsEntry[] | undefined, + localServers: MCPServerSettingsEntry[] | undefined, +): ResolvedMCPServerConfig[] { + if (localServers !== undefined) { + const localResolved = expandMcpServers(localServers); + if (hasExaEntry(localServers) || globalExaSuppressesBuiltin(globalServers)) + return localResolved; + return [createExaMCPServerConfig(), ...localResolved]; + } + + if (globalServers !== undefined) { + const globalResolved = expandMcpServers(globalServers); + if (hasExaEntry(globalServers)) return globalResolved; + return [createExaMCPServerConfig(), ...globalResolved]; + } + + return [createExaMCPServerConfig()]; +} + // Build the OpenAI-compatible InferenceSource the runtime consumes. `id` is the // user-facing name for this source (e.g. "zen"); `provider` is always // "openai-compatible" so the inference registry routes it to the right adapter. @@ -329,11 +374,8 @@ export interface Config { // Per-call total wall-clock cap in ms (default 600_000 in the harness). totalTimeoutMs?: number; reasoningEffort?: ReasoningEffort; - mcpServers?: MCPServerConfig[]; - /** - * Where `mcpServers` came from. Local project settings replace global MCP - * entirely; only `"local"` sources require project trust before connect. - */ + mcpServers?: ResolvedMCPServerConfig[]; + /** Local project MCP lists replace global lists and require project trust. */ mcpServersSource?: "local" | "global" | "none"; sessionId: string; /** When true, runTUI shows a session picker first (resume flow). */ @@ -773,10 +815,19 @@ export async function loadConfig( ...(profile.totalTimeoutMs !== undefined ? { totalTimeoutMs: profile.totalTimeoutMs } : {}), ...(local?.reasoningEffort !== undefined ? { reasoningEffort: local.reasoningEffort } : {}), ...(local?.mcpServers !== undefined - ? { mcpServers: local.mcpServers, mcpServersSource: "local" as const } + ? { + mcpServers: resolveMcpServers(settings?.mcpServers, local.mcpServers), + mcpServersSource: "local" as const, + } : settings?.mcpServers !== undefined - ? { mcpServers: settings.mcpServers, mcpServersSource: "global" as const } - : { mcpServersSource: "none" as const }), + ? { + mcpServers: resolveMcpServers(settings.mcpServers, undefined), + mcpServersSource: "global" as const, + } + : { + mcpServers: resolveMcpServers(undefined, undefined), + mcpServersSource: "none" as const, + }), // Runtime view includes OAuth projections so inference resolution can see // Codex/xAI providers that are never written to settings.json. Not safe // to persist as-is — use providerCatalogToSettings or re-read disk. diff --git a/src/config/settings.ts b/src/config/settings.ts index 84ace785e..775dd42c7 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -6,6 +6,7 @@ import { dirname, join } from "node:path"; import { type } from "arktype"; import { SETTINGS_DIR_NAME } from "../branding.js"; +import { EXA_MCP_SERVER_NAME } from "../mcp/exa.js"; import { REASONING_EFFORTS, isReasoningEffort, @@ -73,7 +74,7 @@ export const DEFAULT_RECENT_MODELS_SHOWN = 5; export interface Settings { defaultProvider?: string; providers: Record; - mcpServers?: MCPServerConfig[]; + mcpServers?: MCPServerSettingsEntry[]; // Per-phase model overrides for workflows. Keyed by profile name, then by // workflow step profile key. Example: // { "fast": { "implement": "gpt-4o-mini", "review": "gpt-4o" } } @@ -305,13 +306,20 @@ export interface MCPServerConfig { url?: string; } +export interface ExaMCPPresetConfig { + name: typeof EXA_MCP_SERVER_NAME; + enabled: boolean; +} + +export type MCPServerSettingsEntry = MCPServerConfig | ExaMCPPresetConfig; + // Per-repo override. Selection only for provider/model, but may also declare // MCP servers to connect at session start. export interface LocalSettings { provider?: string; model?: string; reasoningEffort?: ReasoningEffort; - mcpServers?: MCPServerConfig[]; + mcpServers?: MCPServerSettingsEntry[]; sessionMode?: SessionMode; // Per-project env vars applied to the run_shell tool's spawn environment (in // addition to the process's own inherited environment). Configuration @@ -456,6 +464,7 @@ const SettingsSchema = type({ // Per-entry MCP shape without the name key. The "exactly one transport" rule is // a cross-field constraint enforced after the structural check. const McpEntrySchema = type({ + "enabled?": "boolean", "type?": "'stdio' | 'http'", "command?": "string", "args?": "string[]", @@ -492,21 +501,38 @@ export function isSettings(value: unknown): value is Settings { return true; } -function isMCPServerConfigEntry(value: unknown): value is Omit { +function isMCPServerConfigEntry( + name: string, + value: unknown, +): value is Omit { if (!McpEntrySchema.allows(value)) return false; const s = value as Record; + if (s.enabled !== undefined) { + return ( + name === EXA_MCP_SERVER_NAME && + s.type === undefined && + s.command === undefined && + s.args === undefined && + s.env === undefined && + s.url === undefined + ); + } // Exactly one transport must be specified. const isHttp = s.type === "http" || (s.type === undefined && typeof s.url === "string"); return isHttp ? typeof s.url === "string" : typeof s.command === "string"; } -function isMCPServerConfigWithKey(value: unknown): value is MCPServerConfig { +function isMCPServerConfigWithKey(value: unknown): value is MCPServerSettingsEntry { if (typeof value !== "object" || value === null) return false; - if (typeof (value as Record).name !== "string") return false; - return isMCPServerConfigEntry(value); + const name = (value as Record).name; + if (typeof name !== "string") return false; + return isMCPServerConfigEntry(name, value); } -function normalizeMcpEntry(name: string, entry: Record): MCPServerConfig { +function normalizeMcpEntry(name: string, entry: Record): MCPServerSettingsEntry { + if (entry.enabled !== undefined) { + return { name: EXA_MCP_SERVER_NAME, enabled: entry.enabled as boolean }; + } return { name, ...(entry.type !== undefined ? { type: entry.type as "stdio" | "http" } : {}), @@ -519,7 +545,7 @@ function normalizeMcpEntry(name: string, entry: Record): MCPSer // Accepts both array format [{ name, command, ... }] and object format // { "name": { command, ... } }. Returns the normalized array. -export function normalizeMcpServers(value: unknown): MCPServerConfig[] | undefined { +export function normalizeMcpServers(value: unknown): MCPServerSettingsEntry[] | undefined { if (value === undefined) return undefined; if (Array.isArray(value)) { if (!value.every(isMCPServerConfigWithKey)) return undefined; @@ -530,10 +556,10 @@ export function normalizeMcpServers(value: unknown): MCPServerConfig[] | undefin } if (typeof value === "object" && value !== null && !Array.isArray(value)) { const obj = value as Record; - const entries: MCPServerConfig[] = []; + const entries: MCPServerSettingsEntry[] = []; for (const [key, val] of Object.entries(obj)) { if (typeof key !== "string") return undefined; - if (!isMCPServerConfigEntry(val)) return undefined; + if (!isMCPServerConfigEntry(key, val)) return undefined; entries.push(normalizeMcpEntry(key, val as Record)); } return entries; diff --git a/src/mcp/auth-store.test.ts b/src/mcp/auth-store.test.ts index 32604a47f..8d1335aac 100644 --- a/src/mcp/auth-store.test.ts +++ b/src/mcp/auth-store.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadAuthState, saveAuthState, updateAuthState } from "./auth-store.js"; +const linear = { serverName: "linear", serverURL: "https://mcp.linear.app/mcp" }; + async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "mcp-auth-")); } @@ -12,7 +14,7 @@ describe("mcp auth-store", () => { test("updateAuthState merges concurrent field writes without losing tokens", async () => { const home = await tempHome(); await saveAuthState( - "linear", + linear, { clientInformation: { client_id: "c1", @@ -28,7 +30,7 @@ describe("mcp auth-store", () => { // persists, the verifier write wiped tokens; with updateAuthState, both land. const writes = await Promise.all([ updateAuthState( - "linear", + linear, (state) => { state.tokens = { access_token: "tok", @@ -40,7 +42,7 @@ describe("mcp auth-store", () => { home, ), updateAuthState( - "linear", + linear, (state) => { state.codeVerifier = "verifier-from-other-session"; }, @@ -48,7 +50,7 @@ describe("mcp auth-store", () => { ), ]); - const final = await loadAuthState("linear", home); + const final = await loadAuthState(linear, home); expect(final.tokens?.access_token).toBe("tok"); expect(final.codeVerifier).toBe("verifier-from-other-session"); expect(final.clientInformation?.client_id).toBe("c1"); @@ -61,14 +63,64 @@ describe("mcp auth-store", () => { const home = await tempHome(); await Promise.all( Array.from({ length: 20 }, (_, i) => - saveAuthState("linear", { codeVerifier: `v${String(i)}` }, home), + saveAuthState(linear, { codeVerifier: `v${String(i)}` }, home), ), ); - const final = await loadAuthState("linear", home); + const final = await loadAuthState(linear, home); expect(final.codeVerifier?.startsWith("v")).toBe(true); // No leftover temp files from failed renames. const dir = join(home, ".corbits", "mcp-auth"); - const raw = await readFile(join(dir, "linear.json"), "utf8"); + const files = await Array.fromAsync(new Bun.Glob("linear-*.json").scan(dir)); + expect(files).toHaveLength(1); + const raw = await readFile(join(dir, files[0] ?? "missing"), "utf8"); expect(JSON.parse(raw).codeVerifier).toBe(final.codeVerifier); }); + + test("scopes credentials to normalized endpoint identity", async () => { + const home = await tempHome(); + const originA = { serverName: "exa", serverURL: "https://one.example/mcp" }; + const originB = { serverName: "exa", serverURL: "https://two.example/mcp" }; + const pathB = { serverName: "exa", serverURL: "https://one.example/other?mode=full" }; + const queryB = { serverName: "exa", serverURL: "https://one.example/mcp?mode=full" }; + const equivalent = { serverName: "exa", serverURL: "https://ONE.example:443/mcp#ignored" }; + + await saveAuthState(originA, { codeVerifier: "only-a" }, home); + + expect((await loadAuthState(originA, home)).codeVerifier).toBe("only-a"); + expect(await loadAuthState(originB, home)).toEqual({}); + expect(await loadAuthState(pathB, home)).toEqual({}); + expect(await loadAuthState(queryB, home)).toEqual({}); + expect((await loadAuthState(equivalent, home)).codeVerifier).toBe("only-a"); + }); + + test("ignores legacy name-only auth state without modifying it", async () => { + const home = await tempHome(); + const dir = join(home, ".corbits", "mcp-auth"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "exa.json"), JSON.stringify({ codeVerifier: "legacy-secret" })); + + expect( + await loadAuthState({ serverName: "exa", serverURL: "https://mcp.exa.ai/mcp" }, home), + ).toEqual({}); + expect(JSON.parse(await readFile(join(dir, "exa.json"), "utf8"))).toEqual({ + codeVerifier: "legacy-secret", + }); + }); + + test("bounds the display slug without weakening scoped identity", async () => { + const home = await tempHome(); + const dir = join(home, ".corbits", "mcp-auth"); + const prefixName = "a".repeat(48); + const longName = `${prefixName}/long`; + await saveAuthState( + { serverName: longName, serverURL: "https://long.example/mcp" }, + { codeVerifier: "scoped-secret" }, + home, + ); + + const scopedFiles = await Array.fromAsync(new Bun.Glob(`${prefixName}-*.json`).scan(dir)); + expect(scopedFiles).toEqual([ + `${prefixName}-825ce19c43a3d0135fa8efda61d61c23a13e6917eb90ea42a6cc43744c0b8b5d.json`, + ]); + }); }); diff --git a/src/mcp/auth-store.ts b/src/mcp/auth-store.ts index 3adb050c3..fe972a556 100644 --- a/src/mcp/auth-store.ts +++ b/src/mcp/auth-store.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -17,25 +18,44 @@ export interface MCPAuthState { codeVerifier?: string; } +export interface MCPAuthIdentity { + serverName: string; + serverURL: string; +} + export function mcpAuthDir(home: string = homedir()): string { return join(home, SETTINGS_DIR_NAME, "mcp-auth"); } -// File names are derived from the server name, which is operator-controlled and -// may contain path separators or other unsafe characters; reduce it to a flat -// slug so it can never escape the auth directory. -function authFilePath(serverName: string, home: string): string { - const slug = serverName.replace(/[^a-zA-Z0-9_-]/g, "_"); - return join(mcpAuthDir(home), `${slug}.json`); +function legacyServerSlug(serverName: string): string { + return serverName.replace(/[^a-zA-Z0-9_-]/g, "_") || "server"; +} + +function serverDisplaySlug(serverName: string): string { + return legacyServerSlug(serverName).slice(0, 48); +} + +export function normalizeMCPServerURL(serverURL: string): string { + const url = new URL(serverURL); + url.hash = ""; + return url.toString(); +} + +function authFilePath(identity: MCPAuthIdentity, home: string): string { + const normalizedURL = normalizeMCPServerURL(identity.serverURL); + const digest = createHash("sha256") + .update(JSON.stringify([identity.serverName, normalizedURL])) + .digest("hex"); + return join(mcpAuthDir(home), `${serverDisplaySlug(identity.serverName)}-${digest}.json`); } export async function loadAuthState( - serverName: string, + identity: MCPAuthIdentity, home: string = homedir(), ): Promise { let raw: string; try { - raw = await readFile(authFilePath(serverName, home), "utf8"); + raw = await readFile(authFilePath(identity, home), "utf8"); } catch (err) { if ( typeof err === "object" && @@ -77,11 +97,11 @@ async function writeAuthFile(path: string, state: MCPAuthState): Promise { // Full replace — prefer updateAuthState when mutating a single field so concurrent // writers merge instead of last-writer-wins on a stale snapshot. export async function saveAuthState( - serverName: string, + identity: MCPAuthIdentity, state: MCPAuthState, home: string = homedir(), ): Promise { - const path = authFilePath(serverName, home); + const path = authFilePath(identity, home); const previous = updateChains.get(path) ?? Promise.resolve(); const write = previous.then( () => writeAuthFile(path, state), @@ -100,21 +120,21 @@ export async function saveAuthState( // Load → mutate → save under the per-file chain. Mutator receives a mutable // snapshot of the latest on-disk state; the returned object is what was written. export async function updateAuthState( - serverName: string, + identity: MCPAuthIdentity, mutator: (state: MCPAuthState) => void, home: string = homedir(), ): Promise { - const path = authFilePath(serverName, home); + const path = authFilePath(identity, home); const previous = updateChains.get(path) ?? Promise.resolve(); const run = previous.then( async () => { - const state = await loadAuthState(serverName, home); + const state = await loadAuthState(identity, home); mutator(state); await writeAuthFile(path, state); return state; }, async () => { - const state = await loadAuthState(serverName, home); + const state = await loadAuthState(identity, home); mutator(state); await writeAuthFile(path, state); return state; diff --git a/src/mcp/client-auth-policy.test.ts b/src/mcp/client-auth-policy.test.ts new file mode 100644 index 000000000..f171ab9cd --- /dev/null +++ b/src/mcp/client-auth-policy.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { withMockedModule } from "../../tests/helpers/mock-module.js"; + +let callbackStarts = 0; +let providerCreates = 0; +let transportOptions: unknown[] = []; +let providerServerURL: string | undefined; +let clientConnectError: Error | undefined; + +const authProvider = { resetAuthorization: async () => undefined }; + +await withMockedModule( + import.meta.resolve("@modelcontextprotocol/sdk/client/index.js"), + (real: typeof import("@modelcontextprotocol/sdk/client/index.js")) => ({ + ...real, + Client: class { + async connect(): Promise { + if (clientConnectError !== undefined) throw clientConnectError; + } + async listTools(): Promise<{ tools: [] }> { + return { tools: [] }; + } + async close(): Promise {} + }, + }), +); + +await withMockedModule( + import.meta.resolve("@modelcontextprotocol/sdk/client/streamableHttp.js"), + (real: typeof import("@modelcontextprotocol/sdk/client/streamableHttp.js")) => ({ + ...real, + StreamableHTTPClientTransport: class { + constructor(_url: URL, options?: unknown) { + transportOptions.push(options); + } + get sessionId(): string | undefined { + return undefined; + } + }, + }), +); + +await withMockedModule( + import.meta.resolve("./callback-server.js"), + (real: typeof import("./callback-server.js")) => ({ + ...real, + startCallbackServer: async () => { + callbackStarts += 1; + return { + redirectUrl: "http://127.0.0.1:12345/callback", + expectState: () => undefined, + waitForCode: async () => "code", + close: () => undefined, + }; + }, + }), +); + +await withMockedModule( + import.meta.resolve("./oauth-provider.js"), + (real: typeof import("./oauth-provider.js")) => ({ + ...real, + createOAuthProvider: async (options: { serverURL: string }) => { + providerCreates += 1; + providerServerURL = options.serverURL; + return authProvider; + }, + }), +); + +const { connectMCPServer } = await import("./client.js"); + +describe("HTTP MCP auth policy", () => { + beforeEach(() => { + callbackStarts = 0; + providerCreates = 0; + transportOptions = []; + providerServerURL = undefined; + clientConnectError = undefined; + }); + + test("built-in anonymous Exa treats 401 as a normal failure without OAuth machinery", async () => { + clientConnectError = new Error("401 Unauthorized"); + const result = await connectMCPServer({ + name: "exa", + type: "http", + url: "https://mcp.exa.ai/mcp", + oauth: false, + }); + + expect(result).toEqual({ ok: false, serverName: "exa", error: "401 Unauthorized" }); + expect(callbackStarts).toBe(0); + expect(providerCreates).toBe(0); + expect(transportOptions).toEqual([undefined]); + }); + + test("ordinary HTTP creates endpoint-scoped OAuth and passes it to transport", async () => { + const result = await connectMCPServer({ + name: "exa", + type: "http", + url: "https://CUSTOM.example:443/mcp?mode=full#ignored", + }); + + expect(result.ok).toBe(true); + expect(callbackStarts).toBe(1); + expect(providerCreates).toBe(1); + expect(providerServerURL).toBe("https://custom.example/mcp?mode=full"); + expect(transportOptions).toEqual([{ authProvider }]); + }); +}); diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 5f8c839f6..f6e92a93b 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -4,9 +4,10 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import type { MCPServerConfig } from "../config/settings.js"; import { createOAuthProvider, type CorbitsOAuthProvider } from "./oauth-provider.js"; import { startCallbackServer, type CallbackServer } from "./callback-server.js"; +import { normalizeMCPServerURL } from "./auth-store.js"; +import type { ResolvedMCPServerConfig } from "./exa.js"; import type { McpToolAnnotations } from "./tool-permissions.js"; import { buildStdioMcpProcessEnv } from "./stdio-env.js"; import { MCP_CLIENT_NAME } from "../branding.js"; @@ -37,7 +38,7 @@ export interface MCPConnectOptions { signal?: AbortSignal; } -function isHttpServer(config: MCPServerConfig): boolean { +function isHttpServer(config: ResolvedMCPServerConfig): boolean { return config.type === "http" || (config.type === undefined && config.url !== undefined); } @@ -169,7 +170,7 @@ async function finishClient( } async function connectStdio( - config: MCPServerConfig, + config: ResolvedMCPServerConfig, options: MCPConnectOptions, ): Promise { if (config.command === undefined) @@ -197,37 +198,45 @@ async function connectStdio( } async function connectHttp( - config: MCPServerConfig, + config: ResolvedMCPServerConfig, options: MCPConnectOptions, ): Promise { if (config.url === undefined) return { ok: false, serverName: config.name, error: "http MCP server requires a url" }; - const url = new URL(config.url); - const callback = await startCallbackServer(config.name); - const authProvider = await createOAuthProvider({ - serverName: config.name, - redirectUrl: callback.redirectUrl, - onAuthURL: (name, authUrl) => options.onAuthURL?.(name, authUrl), - onAuthorizationState: callback.expectState, - }); - const makeTransport = (): Transport => - new StreamableHTTPClientTransport(url, { authProvider }) as unknown as Transport; + const normalizedURL = normalizeMCPServerURL(config.url); + const url = new URL(normalizedURL); + let authContext: HTTPAuthContext | undefined; + let makeTransport: () => Transport; + if (config.oauth === false) { + makeTransport = () => new StreamableHTTPClientTransport(url) as unknown as Transport; + } else { + const callback = await startCallbackServer(config.name); + const authProvider = await createOAuthProvider({ + serverName: config.name, + serverURL: normalizedURL, + redirectUrl: callback.redirectUrl, + onAuthURL: (name, authUrl) => options.onAuthURL?.(name, authUrl), + onAuthorizationState: callback.expectState, + }); + makeTransport = () => + new StreamableHTTPClientTransport(url, { authProvider }) as unknown as Transport; + authContext = { + url, + authProvider, + callback, + interactive: options.onAuthURL !== undefined, + serverName: config.name, + ...(options.onAuthorized !== undefined ? { onAuthorized: options.onAuthorized } : {}), + ...(options.signal !== undefined ? { signal: options.signal } : {}), + }; + } const client = new Client({ name: MCP_CLIENT_NAME, version: "1.0.0" }); - const authContext: HTTPAuthContext = { - url, - authProvider, - callback, - interactive: options.onAuthURL !== undefined, - serverName: config.name, - ...(options.onAuthorized !== undefined ? { onAuthorized: options.onAuthorized } : {}), - ...(options.signal !== undefined ? { signal: options.signal } : {}), - }; try { await withHTTPAuthorizationRecovery(authContext, () => client.connect(makeTransport())); return { ok: true, client: await finishClient(client, config.name, authContext) }; } catch (err) { await client.close().catch(() => undefined); - callback.close(); + authContext?.callback.close(); return { ok: false, serverName: config.name, @@ -237,14 +246,14 @@ async function connectHttp( } export async function connectMCPServer( - config: MCPServerConfig, + config: ResolvedMCPServerConfig, options: MCPConnectOptions = {}, ): Promise { return isHttpServer(config) ? connectHttp(config, options) : connectStdio(config, options); } export async function connectMCPServers( - configs: MCPServerConfig[], + configs: ResolvedMCPServerConfig[], onWarning: (message: string) => void, options: MCPConnectOptions = {}, ): Promise { diff --git a/src/mcp/exa.ts b/src/mcp/exa.ts new file mode 100644 index 000000000..47a124bf6 --- /dev/null +++ b/src/mcp/exa.ts @@ -0,0 +1,25 @@ +import type { MCPServerConfig } from "../config/settings.js"; + +export const EXA_MCP_SERVER_NAME = "exa"; +export const EXA_MCP_URL = "https://mcp.exa.ai/mcp"; + +export interface ResolvedMCPServerConfig extends MCPServerConfig { + oauth?: false; + source?: "builtin"; +} + +export function createExaMCPServerConfig(): ResolvedMCPServerConfig { + return { + name: EXA_MCP_SERVER_NAME, + type: "http", + url: EXA_MCP_URL, + oauth: false, + source: "builtin", + }; +} + +export function isBuiltinExaMCPServer(config: MCPServerConfig): boolean { + return ( + config.name === EXA_MCP_SERVER_NAME && (config as ResolvedMCPServerConfig).source === "builtin" + ); +} diff --git a/src/mcp/oauth-provider.test.ts b/src/mcp/oauth-provider.test.ts index 4b791b93a..9e8d8bc1d 100644 --- a/src/mcp/oauth-provider.test.ts +++ b/src/mcp/oauth-provider.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadAuthState, saveAuthState } from "./auth-store.js"; @@ -19,6 +19,8 @@ const clientInfo = (port: number) => ({ client_name: "interchange-code", }); +const linear = { serverName: "linear", serverURL: "https://mcp.linear.app/mcp" }; + async function syncValue(value: T | Promise): Promise { return await value; } @@ -27,7 +29,7 @@ describe("createOAuthProvider", () => { test("drops stale DCR client when redirect port changed and no tokens exist", async () => { const home = await tempHome(); await saveAuthState( - "linear", + linear, { clientInformation: clientInfo(60435), codeVerifier: "old-verifier", @@ -37,13 +39,14 @@ describe("createOAuthProvider", () => { const provider = await createOAuthProvider({ serverName: "linear", + serverURL: linear.serverURL, redirectUrl: "http://127.0.0.1:62000/callback", onAuthURL: () => undefined, home, }); expect(await syncValue(provider.clientInformation())).toBeUndefined(); - const disk = await loadAuthState("linear", home); + const disk = await loadAuthState(linear, home); expect(disk.clientInformation).toBeUndefined(); expect(disk.codeVerifier).toBeUndefined(); }); @@ -51,7 +54,7 @@ describe("createOAuthProvider", () => { test("keeps registered client and tokens when only the loopback port changed", async () => { const home = await tempHome(); await saveAuthState( - "linear", + linear, { clientInformation: clientInfo(60435), tokens: { @@ -66,6 +69,7 @@ describe("createOAuthProvider", () => { const provider = await createOAuthProvider({ serverName: "linear", + serverURL: linear.serverURL, redirectUrl: "http://127.0.0.1:62000/callback", onAuthURL: () => undefined, home, @@ -77,16 +81,18 @@ describe("createOAuthProvider", () => { test("concurrent saveTokens and saveCodeVerifier from two providers keep both fields", async () => { const home = await tempHome(); - await saveAuthState("linear", { clientInformation: clientInfo(1) }, home); + await saveAuthState(linear, { clientInformation: clientInfo(1) }, home); const a = await createOAuthProvider({ serverName: "linear", + serverURL: linear.serverURL, redirectUrl: "http://127.0.0.1:1/callback", onAuthURL: () => undefined, home, }); const b = await createOAuthProvider({ serverName: "linear", + serverURL: linear.serverURL, redirectUrl: "http://127.0.0.1:1/callback", onAuthURL: () => undefined, home, @@ -102,7 +108,7 @@ describe("createOAuthProvider", () => { b.saveCodeVerifier("verifier-b"), ]); - const disk = await loadAuthState("linear", home); + const disk = await loadAuthState(linear, home); expect(disk.tokens?.access_token).toBe("tok-a"); expect(disk.codeVerifier).toBe("verifier-b"); }); @@ -110,7 +116,7 @@ describe("createOAuthProvider", () => { test("resetAuthorization clears client when redirect no longer matches registration", async () => { const home = await tempHome(); await saveAuthState( - "linear", + linear, { clientInformation: clientInfo(60435), tokens: { @@ -126,6 +132,7 @@ describe("createOAuthProvider", () => { const provider = await createOAuthProvider({ serverName: "linear", + serverURL: linear.serverURL, redirectUrl: "http://127.0.0.1:62000/callback", onAuthURL: () => undefined, home, @@ -135,8 +142,103 @@ describe("createOAuthProvider", () => { await provider.resetAuthorization(); expect(await syncValue(provider.tokens())).toBeUndefined(); expect(await syncValue(provider.clientInformation())).toBeUndefined(); - const disk = await loadAuthState("linear", home); + const disk = await loadAuthState(linear, home); expect(disk.clientInformation).toBeUndefined(); expect(disk.tokens).toBeUndefined(); }); + + test("isolates same-name providers by endpoint and persists the same identity", async () => { + const home = await tempHome(); + const customURL = "https://custom.example/mcp"; + const canonicalURL = "https://mcp.exa.ai/mcp"; + const custom = await createOAuthProvider({ + serverName: "exa", + serverURL: customURL, + redirectUrl: "http://127.0.0.1:1/callback", + onAuthURL: () => undefined, + home, + }); + await custom.saveTokens({ access_token: "custom-secret", token_type: "bearer" }); + + const canonical = await createOAuthProvider({ + serverName: "exa", + serverURL: canonicalURL, + redirectUrl: "http://127.0.0.1:1/callback", + onAuthURL: () => undefined, + home, + }); + const customAgain = await createOAuthProvider({ + serverName: "exa", + serverURL: customURL, + redirectUrl: "http://127.0.0.1:1/callback", + onAuthURL: () => undefined, + home, + }); + + expect(await syncValue(canonical.tokens())).toBeUndefined(); + expect((await syncValue(customAgain.tokens()))?.access_token).toBe("custom-secret"); + }); + + test("leaves ordinary and empty-name legacy state inert", async () => { + const home = await tempHome(); + const dir = join(home, ".corbits", "mcp-auth"); + await mkdir(dir, { recursive: true }); + const legacy = JSON.stringify({ tokens: { access_token: "legacy" } }); + await writeFile(join(dir, "exa.json"), legacy); + await writeFile(join(dir, ".json"), legacy); + + const exa = await createOAuthProvider({ + serverName: "exa", + serverURL: "https://mcp.exa.ai/mcp", + redirectUrl: "http://127.0.0.1:1/callback", + onAuthURL: () => undefined, + home, + }); + const emptyName = await createOAuthProvider({ + serverName: "", + serverURL: "https://empty.example/mcp", + redirectUrl: "http://127.0.0.1:1/callback", + onAuthURL: () => undefined, + home, + }); + + expect(await syncValue(exa.tokens())).toBeUndefined(); + expect(await syncValue(emptyName.tokens())).toBeUndefined(); + expect(await readFile(join(dir, "exa.json"), "utf8")).toBe(legacy); + expect(await readFile(join(dir, ".json"), "utf8")).toBe(legacy); + }); + + test("does not delete scoped state whose filename stem is another provider name", async () => { + const home = await tempHome(); + const dir = join(home, ".corbits", "mcp-auth"); + const existingIdentity = { serverName: "exa", serverURL: "https://custom.example/mcp" }; + await saveAuthState( + existingIdentity, + { tokens: { access_token: "scoped-secret", token_type: "bearer" } }, + home, + ); + const [scopedFilename] = await Array.fromAsync(new Bun.Glob("exa-*.json").scan(dir)); + expect(scopedFilename).toBeDefined(); + const collidingName = scopedFilename?.slice(0, -".json".length) ?? "missing"; + + const collidingProvider = await createOAuthProvider({ + serverName: collidingName, + serverURL: "https://other.example/mcp", + redirectUrl: "http://127.0.0.1:1/callback", + onAuthURL: () => undefined, + home, + }); + const existingProvider = await createOAuthProvider({ + ...existingIdentity, + redirectUrl: "http://127.0.0.1:1/callback", + onAuthURL: () => undefined, + home, + }); + + expect(await syncValue(collidingProvider.tokens())).toBeUndefined(); + expect((await syncValue(existingProvider.tokens()))?.access_token).toBe("scoped-secret"); + expect((await loadAuthState(existingIdentity, home)).tokens?.access_token).toBe( + "scoped-secret", + ); + }); }); diff --git a/src/mcp/oauth-provider.ts b/src/mcp/oauth-provider.ts index 01b3a4ac8..4519f27d1 100644 --- a/src/mcp/oauth-provider.ts +++ b/src/mcp/oauth-provider.ts @@ -5,11 +5,12 @@ import type { OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { updateAuthState, type MCPAuthState } from "./auth-store.js"; +import { updateAuthState, type MCPAuthIdentity, type MCPAuthState } from "./auth-store.js"; import { MCP_CLIENT_NAME } from "../branding.js"; export interface OAuthProviderOptions { serverName: string; + serverURL: string; redirectUrl: string; onAuthURL: (serverName: string, authorizationUrl: string) => void; onAuthorizationState?: (state: string) => void; @@ -49,11 +50,15 @@ function replaceStored(stored: MCPAuthState, next: MCPAuthState): void { export async function createOAuthProvider( opts: OAuthProviderOptions, ): Promise { + const identity: MCPAuthIdentity = { + serverName: opts.serverName, + serverURL: opts.serverURL, + }; // Load + scrub stale DCR under the per-file chain so concurrent providers see // the same cleaned state. Mutations always re-read disk; this in-memory mirror // only serves the SDK's sync getters (tokens / clientInformation / codeVerifier). const stored: MCPAuthState = await updateAuthState( - opts.serverName, + identity, (state) => { dropStaleClientRegistration(state, opts.redirectUrl); }, @@ -61,7 +66,7 @@ export async function createOAuthProvider( ); const apply = async (mutator: (state: MCPAuthState) => void): Promise => { - const next = await updateAuthState(opts.serverName, mutator, opts.home); + const next = await updateAuthState(identity, mutator, opts.home); replaceStored(stored, next); }; diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts index b5635b5ee..1a7302316 100644 --- a/src/mcp/plugin.ts +++ b/src/mcp/plugin.ts @@ -13,6 +13,7 @@ import { mcpToolName } from "./tool-name.js"; export interface McpSpillOptions { getBlobWriter?: () => SpillBlobWriter | undefined; getContextDir?: () => string | undefined; + excludeToolNames?: readonly string[]; } // MCP results never reach the posix runner, so the secret-scrub and truncation @@ -34,37 +35,40 @@ export function mcpClientToAgentTools( gate: PermissionGate, spillOptions: McpSpillOptions = {}, ): AgentTool[] { - const { getBlobWriter, getContextDir } = spillOptions; + const { getBlobWriter, getContextDir, excludeToolNames = [] } = spillOptions; + const excluded = new Set(excludeToolNames); - return client.tools.map((tool) => ({ - kind: "full" as const, - definition: { - name: mcpToolName(client.serverName, tool.name), - description: `[${client.serverName}] ${tool.description}`, - inputSchema: tool.inputSchema, - }, - handler: (call: ToolCall, signal: AbortSignal): Promise => - gateToolCall(gate, call, signal, async () => { - try { - const content = await client.call(tool.name, call.arguments, signal); - const writeBlob = getBlobWriter?.(); - const contextDir = getContextDir?.(); - const spill = - writeBlob !== undefined - ? { - callId: call.id, - writeBlob, - ...(contextDir !== undefined ? { contextDir } : {}), - } - : undefined; - return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } - }), - })); + return client.tools + .filter((tool) => !excluded.has(tool.name)) + .map((tool) => ({ + kind: "full" as const, + definition: { + name: mcpToolName(client.serverName, tool.name), + description: `[${client.serverName}] ${tool.description}`, + inputSchema: tool.inputSchema, + }, + handler: (call: ToolCall, signal: AbortSignal): Promise => + gateToolCall(gate, call, signal, async () => { + try { + const content = await client.call(tool.name, call.arguments, signal); + const writeBlob = getBlobWriter?.(); + const contextDir = getContextDir?.(); + const spill = + writeBlob !== undefined + ? { + callId: call.id, + writeBlob, + ...(contextDir !== undefined ? { contextDir } : {}), + } + : undefined; + return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) }; + } catch (err) { + return { + callId: call.id, + content: err instanceof Error ? err.message : String(err), + isError: true, + }; + } + }), + })); } diff --git a/src/settings.test.ts b/src/settings.test.ts index 749cacc5d..d4a57f55b 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -25,6 +25,7 @@ import { setDefaultModel, listRecentModels, listFavoriteModels, + normalizeMcpServers, } from "./config/settings.js"; const firepass: Settings = { @@ -47,6 +48,39 @@ const twoProviders: Settings = { }, }; +describe("MCP settings validation", () => { + test("accepts the Exa preset enabled or disabled in object and array forms", () => { + expect(normalizeMcpServers({ exa: { enabled: true } })).toEqual([ + { name: "exa", enabled: true }, + ]); + expect(normalizeMcpServers({ exa: { enabled: false } })).toEqual([ + { name: "exa", enabled: false }, + ]); + expect(normalizeMcpServers([{ name: "exa", enabled: true }])).toEqual([ + { name: "exa", enabled: true }, + ]); + expect(normalizeMcpServers([{ name: "exa", enabled: false }])).toEqual([ + { name: "exa", enabled: false }, + ]); + }); + + test("rejects empty, unknown transport-less, and mixed preset entries", () => { + expect(normalizeMcpServers({ exa: {} })).toBeUndefined(); + expect(normalizeMcpServers({ unknown: { enabled: true } })).toBeUndefined(); + expect(normalizeMcpServers({ unknown: { enabled: false } })).toBeUndefined(); + expect( + normalizeMcpServers({ exa: { enabled: true, url: "https://mcp.exa.ai/mcp" } }), + ).toBeUndefined(); + expect(normalizeMcpServers({ exa: { enabled: false, command: "custom-exa" } })).toBeUndefined(); + }); + + test("preserves a custom transport-bearing server named Exa", () => { + expect(normalizeMcpServers({ exa: { url: "https://example.test/custom" } })).toEqual([ + { name: "exa", url: "https://example.test/custom" }, + ]); + }); +}); + describe("normalizeOpenAICompatibleBaseURL", () => { test("preserves a plain base URL", () => { expect(normalizeOpenAICompatibleBaseURL("https://provider.example.com/v1")).toBe( diff --git a/src/subagent/run.ts b/src/subagent/run.ts index beb860e27..4e36641e9 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -207,8 +207,11 @@ export function buildSubAgentPrimarySource( // special-case: they pass through applyCapabilityFilter by name like any // other tool (an "explore" intent that wants a read-only leaf can still // exclude them explicitly via capabilities.tools). -export function coreSubAgentWebTools(): AgentTool[] { - return [createWebFetchTool(), createWebSearchTool()]; +export function coreSubAgentWebTools(inherited: readonly AgentTool[] = []): AgentTool[] { + const inheritedNames = new Set(inherited.map((tool) => tool.definition.name)); + return [createWebFetchTool(), createWebSearchTool()].filter( + (tool) => !inheritedNames.has(tool.definition.name), + ); } function applyCapabilityFilter(tools: AgentTool[], capabilities: CapabilityFilter): AgentTool[] { @@ -479,9 +482,9 @@ async function runSubAgentInner( ), })); - tools = [...tools, ...coreSubAgentWebTools()]; - const inherited = params.inheritMcpTools?.() ?? []; + tools = [...tools, ...coreSubAgentWebTools(inherited)]; + if (inherited.length > 0) { tools = [...tools, ...inherited]; } diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts index 9ca99966f..5a68e2937 100644 --- a/src/tools/web-fetch.ts +++ b/src/tools/web-fetch.ts @@ -1,11 +1,12 @@ import { type } from "arktype"; import { stringTool } from "@intx/agent"; import type { AgentTool } from "@intx/agent"; -import type { ToolDefinition } from "@intx/types/runtime"; +import type { ToolCall, ToolDefinition, ToolResult } from "@intx/types/runtime"; import { checkUrlForSsrf } from "./ssrf-guard.js"; import { htmlToMarkdown, htmlToText } from "./html-convert.js"; import { COMMAND_NAME } from "../branding.js"; +import type { MCPClient } from "../mcp/client.js"; import pkg from "../../package.json" with { type: "json" }; export const MAX_FETCH_BYTES = 5 * 1024 * 1024; // 5 MB @@ -29,7 +30,7 @@ const WebFetchArgs = type({ export const webFetchDefinition: ToolDefinition = { name: "web_fetch", description: - "Fetch a web page over HTTP(S) and return its content. Runs in-process with no subprocess or API key. Converts HTML to markdown by default. Use for documentation, articles, and other external references.", + "Fetch a web page over HTTP(S) and return its content. Uses built-in Exa MCP by default, or a direct in-process fetch when that built-in is disabled or overridden. Converts HTML to markdown by default. Use for documentation, articles, and other external references.", inputSchema: { type: "object", properties: { @@ -215,3 +216,55 @@ export function createWebFetchTool(): AgentTool { }, }); } + +type ExaMCPWebFetchConnection = { ok: true; client: MCPClient } | { ok: false; error: string }; + +export function createExaMCPWebFetchTool(args: { + connect: (signal: AbortSignal) => Promise; +}): AgentTool { + return { + kind: "full", + definition: webFetchDefinition, + handler: async (call: ToolCall, signal: AbortSignal): Promise => { + const parsed = WebFetchArgs(call.arguments); + if (parsed instanceof type.errors) { + return { + callId: call.id, + content: + "Error: web_fetch requires a non-empty url (http/https); format and timeout are optional.", + isError: true, + }; + } + const connection = await args.connect(signal); + if (!connection.ok) { + return { + callId: call.id, + content: `Error: Exa MCP web_fetch unavailable: ${connection.error}`, + isError: true, + }; + } + if (!connection.client.tools.some((tool) => tool.name === "web_fetch_exa")) { + return { + callId: call.id, + content: + "Error: Exa MCP web_fetch unavailable: connected Exa server did not advertise web_fetch_exa.", + isError: true, + }; + } + try { + const content = await connection.client.call( + "web_fetch_exa", + { urls: [parsed.url] }, + signal, + ); + return { callId: call.id, content }; + } catch (err) { + return { + callId: call.id, + content: `Error: Exa MCP web_fetch failed: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; +} diff --git a/src/tools/web-search.test.ts b/src/tools/web-search.test.ts index 762aa0296..c5b85fd89 100644 --- a/src/tools/web-search.test.ts +++ b/src/tools/web-search.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { withMockedModule } from "../../tests/helpers/mock-module.js"; +import type { ResolvedMCPServerConfig } from "../mcp/exa.js"; const calls: { toolName: string; args: Record }[] = []; -let connectConfigs: { name: string; url?: string }[] = []; +let connectConfigs: ResolvedMCPServerConfig[] = []; // The mock needs to spread the real module rather than replace it outright, // or any other export (unwrapToolContent, connectMCPServers) disappears for @@ -11,7 +12,7 @@ await withMockedModule( import.meta.resolve("../mcp/client.js"), (real: typeof import("../mcp/client.js")) => ({ ...real, - connectMCPServer: async (config: { name: string; url?: string }) => { + connectMCPServer: async (config: ResolvedMCPServerConfig) => { connectConfigs.push(config); return { ok: true, @@ -36,6 +37,34 @@ const { EXA_MCP_URL, PARALLEL_MCP_URL, } = await import("./web-search.js"); +const { createAgentToolset } = await import("../agent/tools.js"); +const { resolveMcpServers } = await import("../config/index.js"); +const { createExaMCPServerConfig } = await import("../mcp/exa.js"); +const { createPermissionGate } = await import("../permission/gate.js"); + +const BUILTIN_EXA_MCP = createExaMCPServerConfig(); + +async function connectConfiguredMCP(mcpServers?: ResolvedMCPServerConfig[]): Promise { + const toolset = await createAgentToolset({ + cwd: process.cwd(), + permissionGate: createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + }), + onOperatorGate: async () => ({ kind: "cancel" }), + ...(mcpServers !== undefined ? { mcpServers } : {}), + }); + try { + await toolset.connectMCP({ + interactiveAuth: false, + onStatus: () => undefined, + onToolsChanged: () => undefined, + }); + } finally { + await toolset.dispose(); + } +} beforeEach(() => { calls.length = 0; @@ -51,6 +80,20 @@ afterEach(async () => { delete process.env.CORBITS_WEB_SEARCH_API_KEY; }); +describe("Exa MCP preset connection boundary", () => { + test("connects the default Exa preset unless it is explicitly disabled", async () => { + await connectConfiguredMCP(); + expect(connectConfigs).toEqual([BUILTIN_EXA_MCP]); + + connectConfigs = []; + await connectConfiguredMCP(resolveMcpServers([{ name: "exa", enabled: false }], undefined)); + expect(connectConfigs).toHaveLength(0); + + await connectConfiguredMCP(resolveMcpServers([{ name: "exa", enabled: true }], undefined)); + expect(connectConfigs).toEqual([BUILTIN_EXA_MCP]); + }); +}); + describe("resolveWebSearchProvider", () => { test("defaults to exa", () => { expect(resolveWebSearchProvider({})).toBe("exa"); diff --git a/src/tools/web-search.ts b/src/tools/web-search.ts index 549c9ba56..b161655f8 100644 --- a/src/tools/web-search.ts +++ b/src/tools/web-search.ts @@ -5,10 +5,12 @@ import type { ToolDefinition } from "@intx/types/runtime"; import { connectMCPServer, type MCPClient } from "../mcp/client.js"; import type { MCPServerConfig } from "../config/settings.js"; +import { EXA_MCP_URL } from "../mcp/exa.js"; + +export { EXA_MCP_URL } from "../mcp/exa.js"; // Endpoint truth resolved from OpenCode's source (packages/opencode/src/tool/mcp-websearch.ts) // at implementation time: both are public, keyless-by-default hosted MCP servers. -export const EXA_MCP_URL = "https://mcp.exa.ai/mcp"; export const PARALLEL_MCP_URL = "https://search.parallel.ai/mcp"; export type WebSearchProviderId = "exa" | "parallel"; diff --git a/src/trust/project-trust.test.ts b/src/trust/project-trust.test.ts index 8aadfb9cd..4cfeb1151 100644 --- a/src/trust/project-trust.test.ts +++ b/src/trust/project-trust.test.ts @@ -7,6 +7,7 @@ import { resolve } from "node:path"; import { isPluginTrusted, + filterMcpServersForConnect, loadProjectTrust, projectTrustPath, readProjectTrustStore, @@ -28,6 +29,28 @@ async function withTempHome(fn: (home: string, cwd: string) => Promise): P const mcpServer = (name: string): MCPServerConfig => ({ name, command: "node", args: [name] }); describe("project trust store", () => { + test("connects global MCP servers without local trust and fails closed for local lists", async () => { + const servers = [ + { name: "exa", type: "http" as const, url: "https://mcp.exa.ai/mcp" }, + { name: "global", command: "global-mcp" }, + ]; + + await expect( + filterMcpServersForConnect(servers, { + source: "global", + cwd: "/repo/under/test", + store: { trustedPluginPaths: [], trustedMcpFingerprints: [] }, + }), + ).resolves.toEqual(servers); + await expect( + filterMcpServersForConnect(servers, { + source: "local", + cwd: "/repo/under/test", + store: { trustedPluginPaths: [], trustedMcpFingerprints: [] }, + }), + ).resolves.toEqual([]); + }); + test("concurrent plugin trust grants both survive without a corrupt file", async () => { await withTempHome(async (home, cwd) => { await Promise.all([ diff --git a/src/trust/project-trust.ts b/src/trust/project-trust.ts index 92aaeed66..945eb3585 100644 --- a/src/trust/project-trust.ts +++ b/src/trust/project-trust.ts @@ -6,6 +6,7 @@ import { createHash } from "node:crypto"; import { type } from "arktype"; import { getLogger } from "@intx/log"; import type { MCPServerConfig } from "../config/settings.js"; +import { isBuiltinExaMCPServer } from "../mcp/exa.js"; import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "trust"]); @@ -317,6 +318,10 @@ export async function filterMcpServersForConnect( const allowed: MCPServerConfig[] = []; let store = opts.store; for (const server of servers) { + if (isBuiltinExaMCPServer(server)) { + allowed.push(server); + continue; + } if (isMcpServerTrusted(store, server)) { allowed.push(server); continue; diff --git a/tests/unit/mcp.test.ts b/tests/unit/mcp.test.ts index dfa3f1cc3..23d20f356 100644 --- a/tests/unit/mcp.test.ts +++ b/tests/unit/mcp.test.ts @@ -288,15 +288,20 @@ describe("normalizeMcpServers with http transport", () => { }); }); +const acmeAuthIdentity = { serverName: "acme", serverURL: "https://mcp.acme.app/mcp" }; + describe("MCP auth store", () => { const tokens: OAuthTokens = { access_token: "tok", token_type: "Bearer" }; test("round-trips state through disk", async () => { const home = await mkdtemp(join(tmpdir(), "intx-auth-")); try { - expect(await loadAuthState("acme", home)).toEqual({}); - await saveAuthState("acme", { tokens, codeVerifier: "verifier" }, home); - expect(await loadAuthState("acme", home)).toEqual({ tokens, codeVerifier: "verifier" }); + expect(await loadAuthState(acmeAuthIdentity, home)).toEqual({}); + await saveAuthState(acmeAuthIdentity, { tokens, codeVerifier: "verifier" }, home); + expect(await loadAuthState(acmeAuthIdentity, home)).toEqual({ + tokens, + codeVerifier: "verifier", + }); } finally { await rm(home, { recursive: true, force: true }); } @@ -305,8 +310,13 @@ describe("MCP auth store", () => { test("isolates state per server name", async () => { const home = await mkdtemp(join(tmpdir(), "intx-auth-")); try { - await saveAuthState("acme", { tokens }, home); - expect(await loadAuthState("github", home)).toEqual({}); + await saveAuthState(acmeAuthIdentity, { tokens }, home); + expect( + await loadAuthState( + { serverName: "github", serverURL: "https://mcp.github.example/mcp" }, + home, + ), + ).toEqual({}); } finally { await rm(home, { recursive: true, force: true }); } @@ -320,6 +330,7 @@ describe("OAuth provider", () => { const seen: { name: string; url: string }[] = []; const provider = await createOAuthProvider({ serverName: "acme", + serverURL: acmeAuthIdentity.serverURL, redirectUrl: "http://127.0.0.1:5599/callback", onAuthURL: (name, url) => seen.push({ name, url }), home, @@ -340,6 +351,7 @@ describe("OAuth provider", () => { try { const provider = await createOAuthProvider({ serverName: "acme", + serverURL: acmeAuthIdentity.serverURL, redirectUrl: "http://127.0.0.1:0/cb", onAuthURL: () => {}, home, @@ -357,6 +369,7 @@ describe("OAuth provider", () => { try { const first = await createOAuthProvider({ serverName: "acme", + serverURL: acmeAuthIdentity.serverURL, redirectUrl: "http://127.0.0.1:0/cb", onAuthURL: () => {}, home, @@ -364,6 +377,7 @@ describe("OAuth provider", () => { await first.saveTokens({ access_token: "abc", token_type: "Bearer" }); const second = await createOAuthProvider({ serverName: "acme", + serverURL: acmeAuthIdentity.serverURL, redirectUrl: "http://127.0.0.1:0/cb", onAuthURL: () => {}, home, @@ -379,6 +393,7 @@ describe("OAuth provider", () => { try { const provider = await createOAuthProvider({ serverName: "acme", + serverURL: acmeAuthIdentity.serverURL, redirectUrl: "http://127.0.0.1:0/cb", onAuthURL: () => {}, home, @@ -396,7 +411,7 @@ describe("OAuth provider", () => { expect(provider.tokens()).toBeUndefined(); expect(() => provider.codeVerifier()).toThrow("No PKCE code verifier saved"); expect(await provider.state?.()).not.toBe(oldState); - expect(await loadAuthState("acme", home)).toEqual({}); + expect(await loadAuthState(acmeAuthIdentity, home)).toEqual({}); } finally { await rm(home, { recursive: true, force: true }); }