Files
gitops-automation/agent/src/server.js
T
william 732246d4fd Add MCP bridge to claude-agent so Hermes can delegate to the real Claude Code CLI
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 Pro/Max
subscription rather than API credits. This works specifically because it's
the actual claude CLI making the request server-side — the same reason
Hermes itself can't authenticate with the subscription directly (proven
earlier: Anthropic rejects the OAuth token from any client that isn't the
real CLI's exact request fingerprint). Read-only: no Edit/Write/git-push/
git-commit tools, since this is a quick-answer bridge, not a repo editor.

Tested end-to-end locally (built + ran the image, curled the full MCP
handshake: initialize -> tools/list -> tools/call) before pushing — got a
real 'pong' back from the actual claude CLI through the MCP protocol.

To register it with Hermes (lives in its own data volume, not git — see
.env.example comment):
  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 <MCP_BRIDGE_KEY>'
2026-08-23 17:24:46 +00:00

91 lines
3.0 KiB
JavaScript

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(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
const PORT = process.env.PORT || 3001;
const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET;
function verifySignature(req) {
if (!WEBHOOK_SECRET) return false;
const sig = req.get("X-Gitea-Signature");
if (!sig) return false;
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody).digest("hex");
const sigBuf = Buffer.from(sig, "hex");
const expBuf = Buffer.from(expected, "hex");
return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
}
app.get("/healthz", (_req, res) => res.send("ok"));
app.post("/webhooks/gitea", async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send("bad signature");
}
// Ack immediately — Gitea has a short webhook timeout and the review itself takes a while.
res.status(202).send("accepted");
const event = req.get("X-Gitea-Event");
const body = req.body;
try {
if (event === "pull_request" && ["opened", "synchronize"].includes(body.action)) {
const { repository, pull_request: pr } = body;
const [owner, repo] = repository.full_name.split("/");
const review = await reviewPullRequest({
owner,
repo,
ref: pr.head.sha,
cloneUrl: repository.clone_url,
prTitle: pr.title,
prBody: pr.body || "",
});
await postPRComment(owner, repo, pr.number, review);
}
} catch (err) {
console.error("webhook handling failed:", err);
}
});
// 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}`);
});