/** * @verevoir/llm/claude-cli — the embedded MCP tool bridge. * * `--disallowedTools "*"` (kept, per this adapter's own constraint — see * index.ts's file header) removes every BUILT-IN Claude Code tool from * context entirely. It says nothing about a tool exposed through an * explicit `--mcp-config` entry — those are a different mechanism, and * `--strict-mcp-config` (also always passed alongside it) is what * already limits the *set* of MCP servers a call can reach to exactly * the one named here, never anything from the caller's own * machine-wide or project configuration. * * This module generates, per held session, a tiny stdio MCP SERVER — a * throwaway Node script written to a temp file — that `claude` itself * spawns as a subprocess once `--mcp-config` names it. That script * speaks MCP's own published stdio wire format on its stdin/stdout side * (not this adapter's invention) and forwards every `tools/call` it * receives over a loopback TCP hop back into THIS process, where the * caller's own `ToolExecutor` actually runs. `tools/list` and * `initialize` are answered locally by the shim from the tool * declarations baked into it at generation time — no round trip needed * for those. * * WHY A SEPARATE PROCESS AT ALL, RATHER THAN AN IN-PROCESS MCP SERVER. * `claude` only speaks MCP over a transport it spawns or connects to * itself (stdio, or a remote URL) — there is no "hand it a function" * form. A subprocess `claude` spawns is the only shape it accepts, so * the shim exists to BE that subprocess while staying as thin as * possible: every real decision (which tools exist, what a call does) * stays in this process, reached over the loopback TCP hop below. * * WHY TCP, NOT A UNIX DOMAIN SOCKET. A Unix socket would be one file * fewer, but Windows doesn't have them in the same shape and this * package doesn't get to assume a platform — `claude` itself runs there * too. Loopback TCP (`127.0.0.1`, an OS-assigned ephemeral port) is * available everywhere. * * THE SHIM↔BRIDGE WIRE FORMAT (the loopback TCP hop) IS THIS PACKAGE'S * OWN — a trivial newline-delimited `{id, name, arguments}` request / * `{id, result}` response, invented here because nothing outside this * process ever needs to agree with it. Only the shim's STDIO side * (talking to `claude`) has to match a spec neither side of this * repository controls. * * CONFIRMED, NOT RELAYED — THE BUILT-IN-TOOL HALF: `--disallowedTools * "*"` genuinely removes every built-in tool from a session using this * bridge, confirmed by three real, operator-run invocations — see * `session.ts`'s own file header for the full account, including a run * where the model was explicitly asked to invoke Bash and could not. * * STILL OPEN — THE MCP HALF: whether an MCP-declared tool exposed by * this bridge stays callable once `--disallowedTools "*"`/`--mcp-config` * are both in play has NOT been observed on a real invocation that got * far enough to test it — every probe run so far shows `mcp_servers` * empty before a connection is even attempted, with `--safe-mode` the * leading, unconfirmed hypothesis for why. See `session.ts`'s own file * header for the full probe history and the isolating probe this points * to next. * * WHAT DEPENDS ON THIS MODULE, AND WHAT DOES NOT — read this before * assuming a broader change: this module's only imports are Node * built-ins plus a TYPE-ONLY import of `ToolDef`/`ToolExecutor`/`ToolUse` * from this package's own top-level `index.ts` (types that already * exist wherever this file lands). It imports nothing from * `claude-cli/session.ts`, and nothing in `claude-cli/index.ts`'s public * surface changes by this file's mere presence. `session.ts` is what * imports `createToolBridge` and owns spawning the `claude` process this * bridge's `--mcp-config` is handed to — the dependency runs ONE way. * So this module can be reviewed, tested, and even merged with no * caller yet: nothing on `main` invokes `createToolBridge` until * `session.ts` lands on top of it in a later change. That is a * deliberate ordering — the security-critical piece settles first, on * its own, rather than being reviewed only as a detail buried inside a * much larger caller. * * AUTHENTICATION — THE LOOPBACK PORT REQUIRES A SHARED SECRET; IT IS NOT * TRUST-BY-BINDING. Binding to 127.0.0.1 keeps the port unreachable over * the network, but loopback TCP is not filesystem-permissioned: ANY * process already running on the same machine can open a connection to * it, regardless of which user started it. Without a check, that * connection could drive the caller's own `ToolExecutor` directly — this * was confirmed reachable from an ordinary, unrelated socket via this * file's own `mcp-bridge.test.ts` (its `sendToolCall` helper, which never * goes through the generated shim at all) before this fix existed: the * 'forwards an armed tools/call...' test already drove the executor that * way, and passed, with zero authentication. * * THE FIX: `createToolBridge` generates a random 32-byte token once per * bridge and writes it into the generated `--mcp-config`'s own `env` * field for the shim's spawn — a channel that never touches the port, * set at the moment `claude` spawns the shim rather than read from a * file an unrelated process could also read. The shim includes it on * every forwarded `tools/call`; `handleRequest` checks it FIRST, before * anything else (including whether a turn is even armed), with a * constant-time comparison (`tokensMatch`), so an unauthenticated caller * learns nothing about internal state either. A mismatch REFUSES rather * than drops: logged via `console.warn`, and answered on the socket with * an explicit `{ refused: true, reason }` rather than silence — so a * caller (or a test) can tell a refusal from a message that was simply * never sent, the same distinction this package's `refused`/`unreachable` * vocabulary draws everywhere else. * * THE LOOPBACK BINDING IS DEFENSE IN DEPTH ON TOP OF THIS, NOT THE * CONTROL ITSELF — stated plainly rather than implied otherwise: it * rules out network reachability entirely, but does not by itself rule * out another local process attempting a connection. The token is what * actually stops one. * * RELAYED, NOT CONFIRMED: whether `claude`'s own spawn of an * `--mcp-config`-declared server genuinely honours that entry's `env` * field is asserted from the MCP server config format's own * widely-documented shape (the same one `command`/`args` already rest * on), not independently confirmed against a real invocation — the same * category of gap as the rest of this file's still-open MCP question. */ import type { ToolDef, ToolExecutor, ToolUse } from '../index.js'; /** One recorded tool result — matches {@link ChatWithToolLoopResult.toolResults}'s * per-entry shape so `session.ts` can hand these straight through. */ export interface BridgeToolResult { toolUseId: string; content: string; isError: boolean; } /** * Per-turn accounting the bridge accumulates while "armed" for one call. * `arm()` returns this SAME object; the caller reads `toolUses` / * `toolResults` back off it once the turn settles — they mutate in * place as `tools/call` requests arrive, rather than being collected * separately and reconciled afterwards. */ export interface ArmedTurnState { readonly executor: ToolExecutor; readonly maxToolCalls: number; readonly toolUses: ToolUse[]; readonly toolResults: BridgeToolResult[]; } /** * A long-lived MCP tool bridge, bound to one held session's `claude` * process for its whole life — see `session.ts`, which owns spawning * the process this bridge's `--mcp-config` is handed to, and tearing the * bridge down (`close()`) when that process goes away. */ export interface ToolBridge { /** Absolute path of the generated `--mcp-config` JSON. */ readonly mcpConfigPath: string; /** The tool NAMES this bridge's shim was generated with — `session.ts` * compares this against a later call's tools to detect an incompatible * reuse of the same held session (see its own doc comment for why that * refuses rather than silently reconfiguring mid-session). */ readonly toolNames: readonly string[]; /** * Arm the bridge for one turn: which executor answers a `tools/call`, * and how many calls it will forward before refusing further ones with * a "no more tools, answer now" result — this transport's analogue of * the API adapters' iteration cap (see `chatWithToolLoop`'s own doc * comment in index.ts). Only one turn should be in flight on a given * session at a time (`session.ts` enforces this), so a single mutable * "current turn" slot is safe rather than needing per-call routing. */ arm(executor: ToolExecutor, maxToolCalls: number): ArmedTurnState; /** Tear the bridge down: close the TCP server and remove its temp * directory. Idempotent. */ close(): Promise; } /** * Generate and write the shim script + `--mcp-config` JSON into a fresh * temp directory, and start the loopback TCP server the shim's * `tools/call` forwarding connects back to. The returned bridge stays * live for as long as the session's `claude` process does — `session.ts` * owns tearing it down via `close()` when that process exits. */ export declare function createToolBridge(tools: ToolDef[]): Promise; //# sourceMappingURL=mcp-bridge.d.ts.map