Re-add MCP bridge so Hermes can delegate to the real Claude Code CLI

Recreates the PR #17 change (closed without merging, then asked for back
after confirming a real ANTHROPIC_API_KEY would mean separate/duplicate
billing rather than actually using the Pro/Max subscription).

New POST /mcp endpoint (Streamable HTTP transport, stateless — fresh
McpServer+transport per request) exposing one tool, ask_claude_code: runs
the real `claude` binary against a prompt, billed against the subscription
rather than API credits. Works specifically because it's the actual CLI
making the request server-side. Read-only — no Edit/Write/git-push/
git-commit tools.

Also removes litellm-config.yaml's "anthropic-claude" model entry: it only
ever worked when the real claude CLI itself was the caller (proven earlier),
so having it listed as a selectable model was actively misleading — Hermes
picking it directly is exactly what produced the '401: Missing Anthropic
API Key' confusion that led back to this bridge. The MCP tool is the actual
working path now; general_settings.forward_client_headers_to_llm_api is
also removed since nothing uses it anymore.

Tested end-to-end locally again before pushing (built the image, ran it,
full MCP handshake via curl) — real 'pong' from the real claude CLI.
This commit is contained in:
2026-08-23 17:41:49 +00:00
parent ec37245b37
commit 46192837e6
6 changed files with 125 additions and 17 deletions
+71
View File
@@ -0,0 +1,71 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, rm, mkdir } from "node:fs/promises";
import path from "node:path";
const execFileAsync = promisify(execFile);
const WORKSPACE_ROOT = "/workspace";
// Runs the real Claude Code CLI — billed against the Claude Pro/Max subscription
// (CLAUDE_CODE_OAUTH_TOKEN), not per-token API billing. This only works because it's
// the actual `claude` binary making the request: Anthropic rejects the same OAuth token
// used by any other HTTP client (proven earlier — direct curl replicating the same
// request shape gets rejected). Read-only: no git/file-write tools, since this is a
// quick-answer bridge, not a repo-editing agent (claude-agent's own webhook flow already
// owns that for PRs).
async function askClaudeSubscription(prompt) {
await mkdir(WORKSPACE_ROOT, { recursive: true });
const dir = await mkdtemp(path.join(WORKSPACE_ROOT, "mcp-"));
try {
const { stdout } = await execFileAsync(
"claude",
[
"-p", prompt,
"--output-format", "text",
"--permission-mode", "bypassPermissions",
"--disallowedTools", "Bash(git push:*),Bash(git commit:*),Edit,Write,NotebookEdit",
],
{ cwd: dir, maxBuffer: 1024 * 1024 * 32 }
);
return stdout;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// A fresh McpServer per request (stateless transport) — cheap, and avoids any
// cross-request state for what's a single-tool, single-shot bridge.
export function createMcpServer() {
const server = new McpServer({ name: "claude-code-bridge", version: "1.0.0" });
server.registerTool(
"ask_claude_code",
{
description:
"Ask the real Claude Code CLI a question or reasoning task, billed against the " +
"Claude Pro/Max subscription rather than per-token API credits. Use this when " +
"you specifically want Claude's own model rather than whatever the default " +
"routed model provides. Read-only — cannot edit files, push, or commit.",
inputSchema: { prompt: z.string().describe("The question or task to ask Claude") },
},
async ({ prompt }) => {
try {
const text = await askClaudeSubscription(prompt);
return { content: [{ type: "text", text }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
}
}
);
return server;
}
export function mcpAuthMiddleware(req, res, next) {
const key = process.env.MCP_BRIDGE_KEY;
if (!key) return res.status(500).send("MCP_BRIDGE_KEY not configured");
if (req.get("Authorization") !== `Bearer ${key}`) return res.status(401).send("unauthorized");
next();
}