HTTP MCP server that exposes Claude Code CLI as a callable tool for local agents. Any agent running on this host can submit prompts or implementation specs to Claude Code, poll for results, and get back structured output including commit SHAs and branch names.
Listens on 127.0.0.1:3101 — loopback only, no auth required (localhost is the access boundary).
cd /root/ClaudeMCP
cp .env.example .env # fill in CLAUDE_CODE_OAUTH_TOKEN
cp projects.example.json projects.json # edit to match your project paths
npm ci
npm run build
pm2 start ecosystem.config.cjs
pm2 logs claude-mcpExpect startup output like:
{"level":"info","msg":"ClaudeMCP starting","projects":["spoonworks","dash","darksignal"],"port":3101}
{"level":"info","msg":"ClaudeMCP listening","port":3101,"endpoint":"http://127.0.0.1:3101/mcp"}
| Variable | Default | Description |
|---|---|---|
CLAUDE_CODE_OAUTH_TOKEN |
(required) | OAuth token passed to claude -p children |
CLAUDE_BIN |
/root/.local/bin/claude |
Path to the claude CLI binary |
CLAUDE_MCP_PORT |
3101 |
HTTP port to listen on |
CLAUDE_MCP_DB_PATH |
./data/jobs.db |
SQLite database path |
CLAUDE_MCP_MAX_DEPTH |
2 |
Recursion depth limit |
LOG_LEVEL |
info |
Pino log level |
{
"spoonworks": { "path": "/root/spoonworks", "defaultBranch": "main" },
"dash": { "path": "/root/dash", "defaultBranch": "main" },
"darksignal": { "path": "/root/darksignal", "defaultBranch": "main" }
}path— absolute path to the project root (used ascwdforclaude -p)defaultBranch— used byclaude_buildwhen nobaseBranchis specified
Reload without restart: kill -HUP <pid> or pm2 sendSignal SIGHUP claude-mcp.
Clients connect via HTTP POST to http://127.0.0.1:3101/mcp.
Submit a free-form prompt to Claude Code. Returns immediately with a jobId.
// Request
{
"project": "spoonworks",
"prompt": "List the top-level files in this repo.",
"timeoutMs": 60000
}
// Response
{
"jobId": "a1b2c3d4-...",
"state": "queued"
}Options: sessionId (resume a Claude session), allowedTools (intersected with server max), timeoutMs (max 6h, default 30min).
Submit a spec for Claude Code to implement on a branch. Constructs a structured prompt internally that instructs Claude to checkout the branch, implement, run tests, and commit.
// Request
{
"project": "dash",
"spec": "## Goal\nAdd a `/health` endpoint that returns `{ok: true}`.",
"branch": "feat/health-endpoint",
"runTests": true
}
// Response
{
"jobId": "b2c3d4e5-...",
"state": "queued"
}Options: baseBranch (default: project's defaultBranch), runTests (default: true), timeoutMs (max 6h, default 4h).
Poll for job completion.
// Request
{ "jobId": "a1b2c3d4-..." }
// Response (running)
{
"jobId": "a1b2c3d4-...",
"kind": "run",
"project": "spoonworks",
"state": "running",
"enqueuedAt": "2026-04-25T19:00:00.000Z",
"startedAt": "2026-04-25T19:00:00.100Z"
}
// Response (done)
{
"jobId": "a1b2c3d4-...",
"kind": "run",
"project": "spoonworks",
"state": "done",
"enqueuedAt": "2026-04-25T19:00:00.000Z",
"startedAt": "2026-04-25T19:00:00.100Z",
"finishedAt": "2026-04-25T19:00:15.200Z",
"output": "src/index.ts\npackage.json\nREADME.md\n...",
"sessionId": "sess-abc123"
}Output is truncated to last 50KB in the DB column. For builds that committed, commitSha is populated by parsing FINAL COMMIT: <sha> from the output.
Full output is written to data/jobs/<jobId>.log.
// Request
{ "project": "spoonworks", "state": "done", "limit": 10 }
// Response
{
"jobs": [
{
"jobId": "a1b2c3d4-...",
"kind": "run",
"project": "spoonworks",
"state": "done",
"enqueuedAt": "2026-04-25T19:00:00.000Z",
"finishedAt": "2026-04-25T19:00:15.200Z"
}
]
}// Request
{ "jobId": "a1b2c3d4-..." }
// Response
{ "ok": true, "state": "cancelled" }Queued jobs → cancelled immediately. Running jobs → SIGTERM then SIGKILL after 5s.
// Request
{}
// Response
{
"projects": [
{ "name": "spoonworks", "defaultBranch": "main" },
{ "name": "dash", "defaultBranch": "main" },
{ "name": "darksignal", "defaultBranch": "main" }
]
}From any Claude session that has access to this MCP server via its client configuration:
{
"mcpServers": {
"claude-mcp": {
"url": "http://127.0.0.1:3101/mcp"
}
}
}Or use a curl helper to call directly:
curl -s -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "claude_list_projects",
"arguments": {}
}
}'OpenClaw 2026.5+ accepts streamable-http as an MCP transport. One command wires ClaudeMCP in:
openclaw mcp set claude '{"transport":"streamable-http","url":"http://127.0.0.1:3101/mcp"}'
# Gateway hot-reloads on config change; restart only if it doesn't pick up:
# systemctl --user restart openclaw-gatewayAfter registration, OpenClaw agents gain native mcp__claude__claude_run and mcp__claude__claude_job_status tools.
The sessionId parameter on claude_run is the reason to call ClaudeMCP instead of claude -p directly. Pass the sessionId from a previous job's result back on the next call, and Claude continues the prior conversation (--resume <sessionId>) instead of starting cold. ClaudeMCP persists the sessionId alongside each job in SQLite, so it survives daemon restarts.
The calling agent owns the mapping from "thread of work" to sessionId. A simple ledger works:
Per call: read the topic from the ledger, pass sessionId to claude_run (omit for fresh sessions), capture the new sessionId from the job result, write it back. Two rules:
- Don't issue parallel
claude_runcalls on the samesessionId—--resumeraces the conversation file. Serialize per topic; if you need parallel work, use separate topics. - If a sessionId can't be resumed (long-idle, OAuth refresh, claude data wipe),
claude_runreturns an error. Drop the topic from the ledger and retry withoutsessionIdto start fresh.
When ClaudeMCP spawns claude -p, it sets CLAUDE_MCP_DEPTH=<parent+1> in the child environment. If a spawned Claude tries to call claude_run or claude_build on this same MCP server, the server reads CLAUDE_MCP_DEPTH from the x-mcp-depth request header (set by the nested Claude MCP client) and rejects any call where depth >= CLAUDE_MCP_MAX_DEPTH (default 2) with error code recursion_limit_exceeded.
Depth 0 = human/agent calling this MCP directly
Depth 1 = spawned by ClaudeMCP (allowed)
Depth 2 = nested spawn (rejected)
Each project has at most one running Claude job at a time. Subsequent submissions are queued and start automatically when the active job finishes. Callers see state: "queued" until they're up.
A single job can fan out via Claude's internal Agent tool — that doesn't count as a separate ClaudeMCP job.
On startup, any jobs that were running when the daemon crashed are automatically transitioned to interrupted. Callers polling those jobs will see state: "interrupted" and can resubmit if needed.
# Start the server
pm2 start ecosystem.config.cjs
# Submit a job
JOB=$(curl -s -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"claude_run","arguments":{"project":"spoonworks","prompt":"List the top-level files in this repo.","timeoutMs":120000}}}' \
| python3 -c "import json,sys; r=json.load(sys.stdin); print(json.loads(r['result']['content'][0]['text'])['jobId'])")
echo "Job: $JOB"
# Poll until done (check every 5s)
while true; do
STATE=$(curl -s -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"claude_job_status\",\"arguments\":{\"jobId\":\"$JOB\"}}}" \
| python3 -c "import json,sys; r=json.load(sys.stdin); print(json.loads(r['result']['content'][0]['text'])['state'])")
echo "State: $STATE"
[[ "$STATE" == "done" || "$STATE" == "failed" ]] && break
sleep 5
done
# Get output
curl -s -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"claude_job_status\",\"arguments\":{\"jobId\":\"$JOB\"}}}" \
| python3 -c "import json,sys; r=json.load(sys.stdin); d=json.loads(r['result']['content'][0]['text']); print(d.get('output','<no output>'))"Expected: non-empty output listing files, state done.
JOB=$(curl -s -X POST http://127.0.0.1:3101/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"claude_build","arguments":{"project":"spoonworks","spec":"Create a file named DELETE_ME with content '\''hello'\''. Commit it.","branch":"test/delete-me"}}}' \
| python3 -c "import json,sys; r=json.load(sys.stdin); print(json.loads(r['result']['content'][0]['text'])['jobId'])")
# Poll as above, then verify:
# - git -C /root/spoonworks branch --list test/delete-me (branch exists)
# - git -C /root/spoonworks show test/delete-me:DELETE_ME (file exists)
# - output contains FINAL COMMIT: <sha>
# - commitSha field populated in job statusSubmit a job with a prompt that instructs Claude to call claude_run against this MCP. The nested call should fail with recursion_limit_exceeded because CLAUDE_MCP_DEPTH=1 when the child runs, and the server will reject any call at depth >= 2.
# Submit a long-running job
JOB=$(... submit claude_run with timeoutMs=300000 ...)
# Kill the daemon while it's running
pm2 delete claude-mcp
# Restart
pm2 start ecosystem.config.cjs
# Poll the old job
curl ... claude_job_status $JOB
# Expect: state: "interrupted"