From 46192837e656ba38a8c3c430ead6512903016b5f Mon Sep 17 00:00:00 2001 From: William Turner Date: Sun, 23 Aug 2026 17:41:49 +0000 Subject: [PATCH] Re-add MCP bridge so Hermes can delegate to the real Claude Code CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 6 ++++ agent/package.json | 4 ++- agent/src/mcpBridge.js | 71 ++++++++++++++++++++++++++++++++++++++++++ agent/src/server.js | 29 +++++++++++++++++ docker-compose.yml | 10 ++++-- litellm-config.yaml | 22 ++++++------- 6 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 agent/src/mcpBridge.js diff --git a/.env.example b/.env.example index e61d725..f38031b 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,12 @@ GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu//' +MCP_BRIDGE_KEY= # --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) --- OPENROUTER_API_KEY= diff --git a/agent/package.json b/agent/package.json index 77efbda..3f9eb8a 100644 --- a/agent/package.json +++ b/agent/package.json @@ -8,6 +8,8 @@ "start": "node src/server.js" }, "dependencies": { - "express": "^4.19.2" + "express": "^4.19.2", + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^3.23.8" } } diff --git a/agent/src/mcpBridge.js b/agent/src/mcpBridge.js new file mode 100644 index 0000000..fcf570f --- /dev/null +++ b/agent/src/mcpBridge.js @@ -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(); +} diff --git a/agent/src/server.js b/agent/src/server.js index 34eaaa1..add0fe9 100644 --- a/agent/src/server.js +++ b/agent/src/server.js @@ -1,7 +1,9 @@ import express from "express"; import crypto from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { postPRComment } from "./gitea.js"; import { reviewPullRequest } from "./runner.js"; +import { createMcpServer, mcpAuthMiddleware } from "./mcpBridge.js"; const app = express(); app.use( @@ -56,6 +58,33 @@ app.post("/webhooks/gitea", async (req, res) => { } }); +// MCP bridge — lets Hermes (or anything else speaking MCP) delegate a question to the +// real Claude Code CLI, billed against the subscription. Stateless: a fresh server + +// transport per request, no session tracking needed for a single-tool bridge like this. +app.post("/mcp", mcpAuthMiddleware, async (req, res) => { + const mcpServer = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { + transport.close(); + mcpServer.close(); + }); + try { + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error("MCP request handling failed:", err); + if (!res.headersSent) res.status(500).send("internal error"); + } +}); + +app.get("/mcp", mcpAuthMiddleware, (_req, res) => { + res.status(405).set("Allow", "POST").send("Method Not Allowed"); +}); + +app.delete("/mcp", mcpAuthMiddleware, (_req, res) => { + res.status(405).set("Allow", "POST").send("Method Not Allowed"); +}); + app.listen(PORT, () => { console.log(`claude-agent listening on :${PORT}`); }); diff --git a/docker-compose.yml b/docker-compose.yml index b8f5238..6a80e8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,9 +120,10 @@ services: - "traefik.http.services.hermes-dashboard.loadbalancer.server.port=9119" claude-agent: - # Gitea PR-review only now — no Matrix presence (see hermes above; only one agent - # is meant to be in Matrix). Still triggered by Gitea's pull_request webhook and - # posts review comments there, entirely independent of Matrix/LiteLLM. + # No Matrix presence (see hermes above; only one agent is meant to be in Matrix). + # Two things call this now: Gitea's pull_request webhook (PR review), and Hermes, + # over MCP (POST /mcp), to delegate a question to the real `claude` CLI when it + # specifically wants the Claude subscription instead of whatever LiteLLM routed it to. image: ${GITEA_REGISTRY_IMAGE} container_name: claude-agent restart: unless-stopped @@ -136,6 +137,9 @@ services: # Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing — # Claude Code reads this in preference to ANTHROPIC_API_KEY when both could apply. CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} + # Shared secret for the /mcp bridge endpoint (internal network only either way, but + # this keeps it from being callable by anything that merely reaches the container). + MCP_BRIDGE_KEY: ${MCP_BRIDGE_KEY} volumes: - agent_workspace:/workspace networks: diff --git a/litellm-config.yaml b/litellm-config.yaml index 52cb862..df1ee11 100644 --- a/litellm-config.yaml +++ b/litellm-config.yaml @@ -11,18 +11,15 @@ model_list: model: openrouter/openai/gpt-4o-mini api_key: os.environ/OPENROUTER_API_KEY - # Routes to Anthropic using the CALLER's forwarded Authorization header (the Claude - # Pro/Max subscription OAuth token) instead of a LiteLLM-held API key — billed against - # the subscription, not per-token. CONFIRMED WORKING, but only for the real `claude` - # CLI binary as caller (tested: `claude -p` with ANTHROPIC_BASE_URL pointed here - # returned a real completion). An earlier test with plain curl replicating the same - # request shape failed — Anthropic apparently requires header/fingerprint details only - # the real CLI sends, which LiteLLM faithfully relays but a hand-built request won't - # have. Do NOT expect this to work for other callers (Hermes, generic HTTP clients) — - # they aren't the real CLI and can't reproduce that fingerprint. - - model_name: anthropic-claude - litellm_params: - model: anthropic/claude-sonnet-5 + # NOT included: an "anthropic-claude" model routing to Anthropic via the caller's + # forwarded OAuth header (general_settings.forward_client_headers_to_llm_api). It + # genuinely works — but ONLY when the real `claude` CLI binary is the caller (its + # request carries a header/fingerprint only that binary sends; a hand-built request, + # including Hermes selecting this model directly, gets a hard auth error from + # Anthropic). Having it selectable here caused exactly that confusion once already. + # The actual working path for "Hermes uses the Claude subscription" is the MCP bridge + # at claude-agent's /mcp (agent/src/mcpBridge.js) — it shells out to the real `claude` + # binary server-side instead of trying to make an arbitrary caller impersonate it. litellm_settings: # Callers (Hermes included) send provider-specific params like reasoning_effort that @@ -30,5 +27,4 @@ litellm_settings: drop_params: true general_settings: - forward_client_headers_to_llm_api: true master_key: os.environ/LITELLM_MASTER_KEY