Skip to content

feat: OpenRouter bridge subcommand#577

Merged
biwasxyz merged 3 commits into
mainfrom
feat/openrouter-bridge
Jun 17, 2026
Merged

feat: OpenRouter bridge subcommand#577
biwasxyz merged 3 commits into
mainfrom
feat/openrouter-bridge

Conversation

@biwasxyz

Copy link
Copy Markdown
Collaborator

Adds a bridge CLI 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 inputSchema passthrough), 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 every tool_call and looping to completion (not a single round).

export OPENROUTER_API_KEY=sk-or-...
npx @aibtc/mcp-server@latest bridge "what's the STX balance of SP3..."

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 (--block wins)
  • --max-spend-ustx <n> / --max-spend-sats <n> — forwarded to the server's SPEND_LIMIT_SESSION_* rail (enforced before signing)
  • --list-tools — preview exposed set, no API key needed
  • --model / --network / --max-turns

The allowlist is re-enforced at tools/call time, so the model can't reach a tool outside the exposed set.

Changes

  • src/bridge/index.ts — bridge implementation
  • src/index.tsbridge router entry (lazy import, next to yield-hunter)
  • README.md, CLAUDE.md — docs + OPENROUTER_API_KEY/OPENROUTER_MODEL env note

Testing

npm run build clean. bridge --read-only --list-tools verified (82/327 tools exposed, no spend tools leak into the read-only set); --allow/--block filters 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.

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 arc0btc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • --block winning over --allow is correctly ordered in selectTools.
  • 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.ts matches the yield-hunter pattern — no startup cost when bridge isn't used.
  • Error output uses redactSensitive correctly. 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_VERB regex 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, vote aren't in the current list if those tool names exist.
  • parseArgs calls process.exit(1) on unknown flags. Throwing instead would make it unit-testable without mocking process.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.

biwasxyz added 2 commits June 17, 2026 11:49
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 arc0btc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cycle 2 re-review — all cycle 1 feedback was addressed.

What was fixed:

  • [blocking resolved] Silent cap-hit now surfaces a clear --max-turns limit warning via a completed flag — clean approach.
  • [suggestion resolved] client.close() is now guaranteed via try/finally — covers both the error path and any mid-loop throw from callOpenRouter.
  • [suggestion resolved] toOpenAITool no longer mutates the original inputSchema — the spread pattern ({ type: "object", properties: {}, ...raw }) is exactly right.
  • [nit resolved] callOpenRouter return type now includes role: 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_VERB regex and parseArgs process.exit calls 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.

@biwasxyz biwasxyz merged commit d2572cd into main Jun 17, 2026
5 checks passed
@biwasxyz biwasxyz deleted the feat/openrouter-bridge branch June 17, 2026 06:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants