# clawdforge-mcp Model Context Protocol (MCP) server that bridges to the [clawdforge](http://192.168.0.5:3001/Sulkta-Coop/clawdforge) LAN HTTP service. Drops the clawdforge tool surface into any MCP-aware client — Claude Desktop, Claude Code, Cursor, Zed, custom agents — so the model can delegate sub-tasks to a separate Claude context window via `claude -p`. "Claude talking to Claude," with the auth living in one place on the LAN. ## What it exposes | Tool | Backed by | Use it for | | --------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- | | `clawdforge_healthz` | `GET /healthz` | Verify clawdforge is up and the host's `claude` CLI is authenticated. | | `clawdforge_run` | `POST /run` | Run a one-shot prompt in a fresh Claude subprocess. Single-turn. Returns the parsed result. | | `clawdforge_upload_file` | `POST /files` | Stage a local file on the clawdforge host and get back a `ff_...` token to attach to a `clawdforge_run` call. | | `clawdforge_session_new` | `POST /sessions` | (v0.2) Open a multi-turn session against an agent (default `claude`). Returns a `session_id`. | | `clawdforge_session_turn` | `POST /sessions/{id}/turn` | (v0.2) Send one turn to an existing session. Returns prose text + structured events. | | `clawdforge_session_close` | `DELETE /sessions/{id}` | (v0.2) Close a session explicitly. Idempotent. | | `clawdforge_session_list` | `GET /sessions` | (v0.2) List sessions visible to this server's bearer token. | | `clawdforge_session_get` | `GET /sessions/{id}` | (v0.2) Fetch a session's state (turn_count, last_turn_at, closed_at, live, meta). | The admin endpoints (`/admin/tokens`) are deliberately NOT exposed — token minting is a human-gated operation. ## Install From a checkout of the clawdforge repo: ```sh pip install -e clients/mcp # or with test deps pip install -e 'clients/mcp[test]' ``` This installs: - the `clawdforge_mcp` Python package - a `clawdforge-mcp` console script (alias for `python -m clawdforge_mcp`) ## Configure The server reads configuration from environment variables — your MCP client sets these via its `env` block when it spawns the subprocess. | Variable | Default | Notes | | ------------------------------ | ------------------------ | ----------------------------------------------------------- | | `CLAWDFORGE_URL` | `http://localhost:8800` | Override to your forge host (e.g. `http://192.168.0.5:8800`). | | `CLAWDFORGE_TOKEN` | (required) | App bearer token (`cf_...`). Mint with `/admin/tokens`. | | `CLAWDFORGE_MCP_LOG` | `WARNING` | Optional. Set `INFO` or `DEBUG` for stderr logs. | | `CLAWDFORGE_UPLOAD_ROOT` | process cwd | Allow-root for `clawdforge_upload_file`. Paths must resolve INSIDE this directory after symlink resolution. | | `CLAWDFORGE_UPLOAD_MAX_BYTES` | `104857600` (100 MiB) | Hard cap on the size of a single file upload. Files exceeding this are refused before any bytes are sent. | ### Claude Desktop Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): ```json { "mcpServers": { "clawdforge": { "command": "clawdforge-mcp", "env": { "CLAWDFORGE_URL": "http://192.168.0.5:8800", "CLAWDFORGE_TOKEN": "cf_REPLACE_ME" } } } } ``` Or if you'd rather not rely on `clawdforge-mcp` being on `$PATH`: ```json { "mcpServers": { "clawdforge": { "command": "/usr/bin/python3", "args": ["-m", "clawdforge_mcp"], "env": { "CLAWDFORGE_URL": "http://192.168.0.5:8800", "CLAWDFORGE_TOKEN": "cf_REPLACE_ME" } } } } ``` A ready-to-paste version lives at `examples/claude-desktop.json`. ### Claude Code Pass the config via `--mcp-config`: ```sh claude --mcp-config examples/claude-code.json ``` `examples/claude-code.json` follows the same `mcpServers` schema as Claude Desktop. ### Cursor / Zed / others Any client that follows the MCP server-spawn convention works the same way — point it at the `clawdforge-mcp` command and pass `CLAWDFORGE_URL` and `CLAWDFORGE_TOKEN` in the env block. ## Tool reference ### `clawdforge_healthz` ```jsonc // args: none // returns: {ok, claude_present, claude_version} ``` ### `clawdforge_run` ```jsonc // args: { "prompt": "string (required)", "model": "string (optional, default 'sonnet')", "system": "string (optional system prompt)", "files": ["ff_...", "..."], // optional, from clawdforge_upload_file "timeout_secs": 60 // optional, 5..600 } // returns: {result, duration_ms, stop_reason} ``` `result` is whatever `claude -p --output-format json` produced, auto-parsed to JSON if possible, otherwise a string. When to reach for it: - **Bounded sub-tasks** that don't need to stay in your main conversation context — recipe parsing, log summarization, diff classification. - **Different system prompts** — e.g. spawn a strict JSON-only sub-Claude for one extraction step. - **Cheap parallelism in spirit** — a sequence of `clawdforge_run` calls is fine; each gets its own context window. When NOT to reach for it: - Long multi-turn conversations. - Anything that needs streaming or partial output. - Trivial prompts where the model can just answer in-context — `claude -p` takes seconds even for one-liners. ### `clawdforge_upload_file` ```jsonc // args: { "path": "/abs/or/relative/path/on/host", "ttl_secs": 3600 // optional, 60..86400 } // returns: {file_token, ttl_secs, size} ``` Path is interpreted on the **host running the MCP server** (typically the user's workstation), not whatever sandbox the LLM thinks it's in. The path is constrained to an allow-root: `CLAWDFORGE_UPLOAD_ROOT` (default the MCP server process's current working directory). Both symlinks and `..` traversal are neutralized via `Path.resolve(strict=True)` followed by an `is_relative_to(root)` containment check. Files larger than `CLAWDFORGE_UPLOAD_MAX_BYTES` (default 100 MiB) are refused before any bytes are sent to the forge. Non-regular files (FIFOs, sockets, directories, devices) are refused. ## Sessions (v0.2) v0.2 adds multi-turn session tools. Use them when you need context across multiple turns; for one-shot calls, `clawdforge_run` is still cheaper (no session-create overhead, no ACPX handshake). Sessions auto-close after **1 hour of inactivity** server-side, but explicit close is preferred. ### `clawdforge_session_new` ```jsonc // args: { "agent": "claude", // optional, default "claude" "meta": { "task": "..." } // optional, free-form caller metadata } // returns: {session_id, agent, created_at, cwd?} ``` ### `clawdforge_session_turn` ```jsonc // args: { "session_id": "01HV...ABC", // required, from clawdforge_session_new "prompt": "string", // required "files": ["ff_..."], // optional, from clawdforge_upload_file "timeout_secs": 90 // optional, 5..600 } // returns: two content blocks // [0] plain text — concatenated `text` events (the model's reply) // [1] JSON — {session_id, turn_index, stop_reason, duration_ms, events[]} ``` The two-block layout means the LLM consumer sees the prose reply directly without having to parse JSON, while tool-calling agents that want to introspect the structured event trace can still get it from the second block. Concurrent turns on the same session are serialized server-side. ### `clawdforge_session_close` ```jsonc // args: { "session_id": "01HV...ABC" } // returns: {ok: true} // or: {ok: true, already_closed: true} // idempotent re-close ``` Idempotent. Safe to call multiple times — the server returns `already_closed: true` on a re-close, and we surface that flag verbatim. ### `clawdforge_session_list` ```jsonc // args: { "include_closed": true // optional, default true } // returns: {sessions: [...rows], count} ``` Each row has `session_id`, `agent`, `app_name`, `created_at`, `last_turn_at`, `turn_count`, `closed_at` (nullable), and optional `meta`. ### `clawdforge_session_get` ```jsonc // args: { "session_id": "01HV...ABC" } // returns: {session_id, agent, cwd, created_at, last_turn_at, // turn_count, closed_at, live, meta} ``` `live` is true while the underlying ACPX subprocess is still running. ### Example flow — open, turn, turn, close A typical multi-turn debug session from an MCP client (Claude Desktop, Claude Code, Cursor, etc.): ``` > clawdforge_session_new {"agent": "claude", "meta": {"task": "debug auth flow"}} < {"session_id": "01HV9P1234...", "agent": "claude", "created_at": 1714329600} > clawdforge_session_turn {"session_id": "01HV9P1234...", "prompt": "Read auth.py and explain how the bearer token check works."} < [0] "The auth check happens in `require_app(...)` at line 42..." [1] {"turn_index": 1, "stop_reason": "end_turn", "duration_ms": 5410, "events": [...]} > clawdforge_session_turn {"session_id": "01HV9P1234...", "prompt": "Now show me where the IP CIDR allowlist is enforced."} < [0] "The CIDR check is in `_check_cidr_match` at..." [1] {"turn_index": 2, ...} > clawdforge_session_close {"session_id": "01HV9P1234..."} < {"ok": true} ``` The session_id ties the turns together — clawdforge holds the ACPX context across them, so turn 2 has full awareness of what turn 1 read. ### When to prefer `session_new` over `run` - **Multi-turn investigation.** "Read X… now look at Y… now Z" benefits from accumulated context. With `clawdforge_run` you'd repeat the entire context on every call. - **Long-running agentic tasks.** ACPX exposes structured tool calls; the agent inside the session can `Read`, `Bash`, `Edit` etc. and you'll see those events in the second content block of `session_turn`. - **Stateful prompts.** "We agreed on schema X earlier — now generate the migration for it." Same context window across turns. For one-off prompts ("parse this recipe", "summarize this log"), `clawdforge_run` is still the right call — lower latency, no session lifecycle to manage. ## Threat model — why the upload guards exist The MCP-specific question is: **what can a malicious LLM-driven client do?** A model that has earned the user's trust can socially-engineer them into running a tool call against any path it can name — and an MCP server that reaches the local filesystem is a perfect exfiltration channel. The classic shape is "let me upload your config so I can debug" pointed at `~/.ssh/id_rsa`, `~/.aws/credentials`, `/etc/shadow`, etc. The defaults pin the upload root to the process cwd, which means an MCP server launched from your project directory cannot reach into your home directory at all. If you need broader access, set `CLAWDFORGE_UPLOAD_ROOT` explicitly to a directory you've thought about — never to `/`. Symlink resolution is mandatory: a symlink inside the root that points to `/etc` will be rejected, not followed. ## Testing ```sh pip install -e 'clients/mcp[test]' python -m pytest clients/mcp/tests ``` The tests stub out the HTTP layer with `responses` — no live clawdforge required. ## Operational notes - **stdout is sacred.** The MCP transport pipes JSON-RPC frames over stdin/stdout. Any stray `print()` in the server process corrupts the stream. All diagnostics go to stderr via the `clawdforge_mcp` logger. - **Errors are wrapped, not raised.** Auth failures, transport errors, upstream 502s — all get formatted into a single short `clawdforge error: ...` text content with `isError=True`. Callers see a clean message, not a Python traceback. - **Sync calls under async.** The MCP SDK is asyncio; our HTTP client is blocking `requests`. Each tool offloads to `asyncio.to_thread` so a slow `claude -p` call doesn't stall heartbeats. - **No streaming.** `clawdforge_run` blocks the MCP request until the subprocess returns. MCP clients handle this fine — it's a normal long-running tool call. ## Why this exists clawdforge centralizes the Claude CLI subscription auth on one LAN host so every Sulkta service doesn't need its own login. MCP is the natural integration layer: any MCP client can now treat clawdforge as a native tool surface and call `claude -p` indirectly. Cobb's framing: *"may as well let claude talk to claude."*