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:
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user