MCP bridge: let Hermes delegate to the real Claude subscription (v2) #18

Merged
william merged 1 commits from feat/mcp-claude-bridge into main 2026-08-23 17:42:38 +00:00
6 changed files with 125 additions and 17 deletions
Showing only changes of commit 46192837e6 - Show all commits
+6
View File
@@ -21,6 +21,12 @@ GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu/<your-gitea-username>/<repo-nam
# Run `claude setup-token` interactively (needs a browser + Claude Pro/Max subscription)
# to generate this — it's a long-lived OAuth token, not an API key.
CLAUDE_CODE_OAUTH_TOKEN=
# Any random string — shared secret for claude-agent's /mcp bridge endpoint (see
# agent/src/mcpBridge.js), which lets Hermes delegate a question to the real `claude`
# CLI (billed against the subscription above) via MCP. Register it in Hermes with:
# docker exec hermes hermes config set mcp_servers.claude-code.url http://claude-agent:3001/mcp
# docker exec hermes hermes config set 'mcp_servers.claude-code.headers.Authorization' 'Bearer <this value>'
MCP_BRIDGE_KEY=
# --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) ---
OPENROUTER_API_KEY=
+3 -1
View File
@@ -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"
}
}
+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();
}
+29
View File
@@ -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}`);
});
+7 -3
View File
@@ -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:
+9 -13
View File
@@ -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