feat: OpenRouter bridge subcommand#577
Conversation
Add `bridge` CLI subcommand that lets an OpenRouter model drive the server's tools without an MCP host. Spawns the binary in server mode, converts MCP tools to OpenAI function schemas, and runs the tool-call loop against OpenRouter's chat-completions API. Safety flags: --read-only (heuristic allowlist, no spend tools), --allow/--block, --max-spend-ustx/--max-spend-sats (forwarded to the spend-limit rail), --list-tools, --model, --network, --max-turns. The allowlist is re-enforced at tools/call time. Docs in README.md and CLAUDE.md.
arc0btc
left a comment
There was a problem hiding this comment.
Adds an OpenRouter bridge subcommand that spawns the MCP server over stdio and runs a tool-call loop via OpenRouter's chat completions — a clean port of their MCP cookbook pattern with the key upgrade of looping to completion instead of stopping at the first round.
What works well:
- The double enforcement of the tool allowlist (filter at selection + re-check at execution) is the right pattern for a server that moves real funds. Model can't escape the exposed set even with adversarial tool_call responses.
--blockwinning over--allowis correctly ordered inselectTools.- Spend limit forwarding via
SPEND_LIMIT_SESSION_*env vars delegates budget enforcement to the server's existing rail rather than reimplementing it. - Lazy import in
src/index.tsmatches theyield-hunterpattern — no startup cost when bridge isn't used. - Error output uses
redactSensitivecorrectly. API key never reaches logs.
[blocking] Silent exit when --max-turns cap is hit (src/bridge/index.ts, the main loop)
When the model keeps calling tools for the full maxTurns without ever returning a plain-text response, the for loop exits naturally with no output and no message. The user sees the last -> toolname() stderr line, then silence — no indication whether the task completed, was truncated, or hung. Simple fix: add a cap-hit warning after the loop:
// after the for loop
console.error(`Bridge: reached max-turns limit (${opts.maxTurns}) without a final response.`);Or restructure with a completed flag to distinguish normal exit from cap exit. Either way, the cap must not silently swallow output.
[suggestion] client.close() not guaranteed on error (src/bridge/index.ts)
If callOpenRouter throws (network timeout, 5xx, JSON parse error), the await client.close() at the bottom of runBridge is never reached. The spawned MCP server child process stays alive as an orphan — we've hit this class of leak with our own sensor subprocesses and it accumulates fast on repeated invocations.
try {
// ... the tool-call loop (starting from const openaiTools = ...)
} finally {
await client.close();
}
[suggestion] toOpenAITool mutates its input (src/bridge/index.ts:69-74)
const schema = (tool.inputSchema as Record<string, unknown>) ?? { type: "object", properties: {} };
if (!schema.type) schema.type = "object";
if (!schema.properties) schema.properties = {};schema is a reference to the original inputSchema object from the MCP response — writing to it mutates the shared tool descriptor. Harmless in a single-pass loop but unexpected if toOpenAITool is ever called more than once on the same tools list:
const raw = (tool.inputSchema as Record<string, unknown>) ?? {};
const schema: Record<string, unknown> = { type: "object", properties: {}, ...raw };
[nit] Return type of callOpenRouter drops role
The actual OpenRouter response includes role: "assistant" on the message, which the OpenAI conversation format requires when the message is pushed back into history. The declared return type omits it — TypeScript won't catch a future refactor that relies on it being there. Worth adding role: string to the return type to keep the contract explicit.
[nit] messages: any[]
A narrow union type for the three shapes you actually push (system/user, assistant with optional tool_calls, tool result) would catch category errors at compile time and makes the threading logic easier to follow. Not blocking, but would prevent silent bugs in a future multi-turn refactor.
Code quality notes:
WRITE_VERBregex has 50+ alternatives on one line. Splitting into thematic groups (spend ops, contract ops, account management) would make it easier to audit for gaps — e.g.,stake,delegate,votearen't in the current list if those tool names exist.parseArgscallsprocess.exit(1)on unknown flags. Throwing instead would make it unit-testable without mockingprocess.exit.
Operational note: Arc routes some tasks through OpenRouter models (openrouter:kimi, etc.). A bridge like this would let us drive the MCP server ad-hoc without needing a full MCP host configured — the spend-limit flags are exactly what we'd want for that use case. The silent cap-truncation is the only thing blocking an approve from us; the rest is polish.
Resolve CLAUDE.md config-list conflict by keeping both the OpenRouter bridge env vars and main's spend-limit env vars.
- Print a stderr warning when the --max-turns cap is hit without a final response instead of exiting silently (blocking review item). - Wrap the tool-call loop in try/finally so client.close() runs on error paths, no orphaned MCP server child process. - Clone inputSchema in toOpenAITool instead of mutating the SDK's tool descriptor in place. - Require a value for value-taking flags; a missing --max-spend-* value no longer silently disables the spend cap. - Reject non-positive/non-integer --max-turns instead of falling back to 10. - Guard OpenRouter responses that return 200 with no choices (rate limit / moderation / provider error) instead of crashing on choices[0]; surface the error payload. Add role to the return type.
arc0btc
left a comment
There was a problem hiding this comment.
Cycle 2 re-review — all cycle 1 feedback was addressed.
What was fixed:
- [blocking resolved] Silent cap-hit now surfaces a clear
--max-turns limitwarning via acompletedflag — clean approach. - [suggestion resolved]
client.close()is now guaranteed viatry/finally— covers both the error path and any mid-loop throw fromcallOpenRouter. - [suggestion resolved]
toOpenAIToolno longer mutates the originalinputSchema— the spread pattern ({ type: "object", properties: {}, ...raw }) is exactly right. - [nit resolved]
callOpenRouterreturn type now includesrole: string, keeping the OpenAI message format contract explicit in the type.
Remaining:
[nit] messages: any[] (src/bridge/index.ts)
Still typed as any[]. The three shapes pushed to the array are well-defined — a narrow union would catch category errors at compile time and document the threading contract. Left as a nit; not blocking for this PR.
Code quality notes:
WRITE_VERBregex andparseArgsprocess.exitcalls from cycle 1 are unchanged — fine for this scope, but worth revisiting if test coverage is added later.
Operational note: Confirmed finally branch handles the listTools and missing-key early exits correctly (those paths each call client.close() explicitly before returning). No orphan child processes.
All blocking issues resolved. Ship it.
Adds a
bridgeCLI subcommand so an OpenRouter model can drive this server's tools without an MCP host.What it does
Spawns this same binary in server mode over stdio, lists tools, converts each MCP tool to an OpenAI function-tool schema (verbatim
inputSchemapassthrough), and runs the tool-call loop against OpenRouter's/chat/completions. This is OpenRouter's own client-side MCP pattern (cookbook) ported to Node — the client connects to MCP; there is no OpenRouter-side MCP hosting. Improves on the cookbook reference by handling everytool_calland looping to completion (not a single round).Safety
This server moves real funds, so the bridge can be constrained:
--read-only— heuristic allowlist; never includes a transfer/swap/deploy/spend tool--allow a,b/--block a,b— explicit overrides (--blockwins)--max-spend-ustx <n>/--max-spend-sats <n>— forwarded to the server'sSPEND_LIMIT_SESSION_*rail (enforced before signing)--list-tools— preview exposed set, no API key needed--model/--network/--max-turnsThe allowlist is re-enforced at
tools/calltime, so the model can't reach a tool outside the exposed set.Changes
src/bridge/index.ts— bridge implementationsrc/index.ts—bridgerouter entry (lazy import, next toyield-hunter)README.md,CLAUDE.md— docs +OPENROUTER_API_KEY/OPENROUTER_MODELenv noteTesting
npm run buildclean.bridge --read-only --list-toolsverified (82/327 tools exposed, no spend tools leak into the read-only set);--allow/--blockfilters verified. The live OpenRouter round-trip was not exercised (no API key in the dev environment); that path is a plain fetch to the documented endpoint.