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: