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>'
72 lines
2.8 KiB
JavaScript
72 lines
2.8 KiB
JavaScript
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();
|
|
}
|