Skip to content

cunninghambe/ClaudeMCP

Repository files navigation

ClaudeMCP

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).

Quickstart

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-mcp

Expect 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"}

Environment Variables

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

projects.json

{
  "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 as cwd for claude -p)
  • defaultBranch — used by claude_build when no baseBranch is specified

Reload without restart: kill -HUP <pid> or pm2 sendSignal SIGHUP claude-mcp.

MCP Tools

Clients connect via HTTP POST to http://127.0.0.1:3101/mcp.

claude_run

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).

claude_build

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).

claude_job_status

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.

claude_jobs_list

// 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"
    }
  ]
}

claude_job_cancel

// Request
{ "jobId": "a1b2c3d4-..." }

// Response
{ "ok": true, "state": "cancelled" }

Queued jobs → cancelled immediately. Running jobs → SIGTERM then SIGKILL after 5s.

claude_list_projects

// Request
{}

// Response
{
  "projects": [
    { "name": "spoonworks", "defaultBranch": "main" },
    { "name": "dash",       "defaultBranch": "main" },
    { "name": "darksignal", "defaultBranch": "main" }
  ]
}

Registering from Another Claude Session

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": {}
    }
  }'

Registering from OpenClaw

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-gateway

After registration, OpenClaw agents gain native mcp__claude__claude_run and mcp__claude__claude_job_status tools.

Session resume across calls

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:

// claude-sessions.json — keyed by topic slug the agent picks
{
  "spoonworks-orders-bug":    { "sessionId": "9f2d4e8a-…", "project": "spoonworks", "lastUsedAt": "2026-05-08T20:30:00Z" },
  "darksignal-pricing-model": { "sessionId": "5e7a1b3c-…", "project": "darksignal", "lastUsedAt": "2026-05-08T18:15:00Z" }
}

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_run calls on the same sessionId--resume races 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_run returns an error. Drop the topic from the ledger and retry without sessionId to start fresh.

Recursion Guard

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)

Per-Project Serialization

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.

Restart Resilience

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.

Smoke Test Plan

§6.3 — Basic claude_run

# 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.

§6.4 — claude_build smoke

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 status

§6.5 — Recursion guard

Submit 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.

§6.6 — Restart resilience

# 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"

About

HTTP MCP server exposing Claude Code as a build-delegation tool for non-Claude agents (Hermes, Paperclip, etc.)

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors