pi-mcp
by sr9000
README.md
# pi-mcp
MCP server for running and supervising [`pi`](https://github.com/earendil-works/pi)
coding agents over RPC. The design keeps a pi process alive between turns (hot
resume); an explicit `resurrect` cold-resumes a ripped or exited agent from its
session file.
> Status: SQLite-backed agents, bwrap-caged `pi --mode rpc` processes,
> normalized mounts, idle keep-alive, logs, chained agents, callback settle
> notifications, per-child token/MCP config patching, per-agent context budgets
> that trigger pi autocompaction, rip/resurrect with lifecycle counters, child
> mounts scoped to their parent agent, background agent refresh, and focused
> runner, store, artifact, and server modules.
## Quick start
```bash
uv sync --extra dev
PI_MCP_DB=/tmp/pi-mcp.sqlite3 \
PI_MCP_AGENTS_DIR=/tmp/pi-mcp/agents \
PI_MCP_ACCESS_TOKEN=alpha \
uv run pi-mcp
```
`run` needs `pi` and `bubblewrap` (`bwrap`) on the host. `pi-mcp-fake` can still
seed fake sessions. stdio is the default transport. For HTTP:
```bash
uv run pi-mcp --transport streamable-http --host 0.0.0.0 --port 8000
```
Current tools:
| Tool | Arguments | Returns |
| -------------- | ---------------------------------------------------------------- | ----------------------------------------------------- |
| `list` | none | Agents visible to the caller's token |
| `get_info` | `agent_id` | Metadata, public state, last assistant text |
| `run` | `provider`?, `model`?, `context_size`?, mounts, callbacks/tokens | Starts a caged pi RPC agent and waits for handshake |
| `rip` | `agent_id` | Stops the process but keeps session and properties |
| `resurrect` | `agent_id`, `prompt`? | Cold-resumes with stored properties and optional turn |
| `send_message` | `agent_id`, `message`, `mode`? | Prompts an idle agent or queues for a running one |
| `remove` | `agent_id` | Stops the process and deletes its artifact directory |
| `grep` | `agent_id`, `args[]`, `stream`? | Proxy the real host `grep` against a log stream |
| `list_tmp` | `agent_id`, `path`?, `recursive`?, `max_entries`? | List forwarded `/tmp` files with type, size, mtime |
| `grep_tmp` | `agent_id`, `args[]`, `cwd`?, `path`? | Proxy the real host `grep` in the agent's `/tmp` |
| `read_tmp` | `agent_id`, `path`, ranges/caps | Read a file by line or byte range |
| `read` | `agent_id`, `stream`, ranges/caps | Read the stdout or errors log |
See [Tool reference](#tool-reference) for full arguments and response examples.
The main transport process is bound to one `PI_MCP_ACCESS_TOKEN`; per-request
HTTP auth for that transport is not implemented yet. The generated child MCP
endpoint does use per-request bearer authentication.
## Chaining agents
Every caged agent receives `AGENT_ID` in its environment. To have a child report
back, call `run(..., callback_agent_id=AGENT_ID)` from inside the parent. When
the child settles, pi-mcp prompts the parent with the child's `agent_id`, last
text, error/exit code, session, and artifact paths. The parent can then use
`list_tmp`, `read`, `grep`, `get_info`, and `read_tmp` on that id.
Agents get a generated `/tmp/pi-agent/mcp.json` that points at a loopback
pi-mcp endpoint, so a caged agent can call the same server. The config carries a
bearer token bound to that agent. When an agent starts a child, the child's
`write`/`read`/`denied` rules are narrowed to the parent's mounts: it cannot
request paths outside the parent's grants, cannot enter a parent-denied path,
and inherits parent read-only overrides and denials. Each child's stored rules
are already scoped, so validation is transitive without walking the parent
chain. A parent with no mount grants cannot give a child new mounts. Callback validation happens before the
child starts; unknown ids abort without creating artifacts. Callback delivery
may cross groups by id, but artifact tools remain group-scoped. Set
`PI_MCP_CHILD_MCP=0` to disable the child endpoint and generated config.
## Token patching
Pass `run(access_token="...")` to put a child in another token group. A known
token joins its group; an unknown token creates one. Only that child's private
`mcp.json` is patched, with a new server-minted token bound to the child agent;
the explicit group token is never copied into the config. Global and project pi
MCP config are never rewritten, and the minted token is mode `0600` in the child
config, is not returned by tools, and is not stored in SQLite.
The loopback endpoint accepts only tokens minted for an agent; arbitrary bearer
values and raw group tokens are rejected. Tokens are revoked when the agent
exits, is ripped, or is removed.
Without `access_token`, the child stays in the caller's group and receives an
ephemeral capability token. This keeps chained parents able to inspect their
children. With an explicit token, the parent cannot see the child through normal
group-scoped tools; use `callback_agent_id` for the result.
## Context limits
Pass `run(context_size=50000)` to give an agent a lower pi context trigger.
`context_size` has a 20,000-token minimum. pi-mcp writes a private
`pi-agent/models.json` with `contextWindow = context_size + headroom`, where
`headroom = max(context_size // 10, 10000)`. It writes `reserveTokens` equal to
the same headroom, so autocompaction starts when the projected context exceeds
`context_size`. The headroom is room for the response and summary call; without
it pi can clamp the summary to one token. The summary keeps 10% of
`context_size` as recent tokens; this is hardcoded and not configurable. The
setting is shown as `context_size` by `list` and `get_info`.
The budget is an accounting override, not the provider's real window. The server
only requires an integer of at least 20,000; it does not resolve provider
catalogs. Keep `context_size + headroom + model.maxTokens` below the model's
actual window. If the resulting window is at or above the real one, compaction
turns into overflow recovery. Without `context_size`, pi's own model window and
defaults apply.
## Agent states
`list`, `get_info`, `rip`, and `resurrect` expose one public `state`:
- `active` — the process is live and a turn is running.
- `idle` — the process is live and settled.
- `ripped` — the process was explicitly stopped; the session is resumable.
- `exited` — the process ended on its own; the session is resumable.
A background thread refreshes each live agent with a configurable pause. The
default is one second, so a full lap takes `pause × live agents`. Configure it
at server start with `--refresh-pause` or `PI_MCP_REFRESH_PAUSE`; `0` disables
the background thread. Natural exits are marked `exited` and their child tokens
are revoked during this lap. `rip` is immediate and marks the agent `ripped`.
## Rip and resurrect
`rip(agent_id)` closes the pi process but keeps the agent record, session
file, logs, forwarded `/tmp`, and original spawn settings. Its state becomes
`ripped`. It is idempotent; a second rip does not increment the counter.
`resurrect(agent_id, prompt=...)` starts a new process for the same agent id and
session using the stored provider, model, `context_size`, mount rules, token
group, and callback target. Those properties cannot be overridden. If `prompt`
is supplied, the resurrected agent starts that turn immediately; otherwise it
returns idle.
`get_info` and `list` expose lifecycle counters:
```json
{
"lifecycle": {
"retries": 1,
"rips": 1,
"self_exits": 1,
"ripped_at": "2026-11-01T12:00:00Z",
"resurrected_at": "2026-11-01T12:05:00Z"
}
}
```
- `rips` counts successful explicit rip transitions.
- `retries` counts successful resurrect calls.
- `self_exits` counts processes that exited without being ripped, removed, or
shut down with the server. This is the counter for “the agent exited on its
own.”
`send_message` still requires a running process; automatic cold resume is not
implemented. Call `resurrect` first; the state returns to `idle` or `active`.
A second prompt after resurrection reuses the new process, just like a hot
prompt.
## Agent artifacts
`PI_MCP_AGENTS_DIR` (or `--agents-dir`) selects the artifact root. It defaults
to `<db-parent>/agents`. Each agent gets one directory:
```text
$PI_MCP_AGENTS_DIR/<agent_id>/
stdout.log
errors.log
agent_session.jsonl
tmp/ # bind-mounted at /tmp inside the cage
```
The session file is mounted read-write at `/agent_session.jsonl`. `tmp/` is
mounted read-write at `/tmp`, so files the agent writes there are forwarded to
the host. Use `list_tmp` to inspect the tree, `grep_tmp` to proxy the host
`grep`, and `read_tmp` to read or download files. `read` selects the stdout or
errors stream. Reads default to `lines_from=0` with `max_bytes=1024`; line
ranges are 0-based and end-exclusive.
Use `bytes_from`/`bytes_to` instead for exact byte ranges. Long lines over 10k
chars are truncated with `...<truncated N chars>`, warnings, and byte offsets in
`truncated_regions`. Binary data is returned as base64. `grep` proxies the
host binary against the selected log path. Read/list paths are
scoped to that agent's `tmp/`, and traversal or symlinks escaping it are
rejected. The directory survives process exit; `remove` deletes it. `get_info`
returns all paths under `artifacts`.
## Tool reference
All successful tool results are returned in MCP `structuredContent`. IDs, paths,
byte counts, and timestamps in the examples are illustrative. Tool failures are
returned as MCP tool errors with `isError: true`.
### `list`
List all sessions visible to the caller's token group.
| Argument | Type | Required | Default | Notes |
| -------- | ---- | -------- | ------- | ------------- |
| — | — | — | — | No arguments. |
```json
{
"count": 1,
"agents": [
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"name": "deepseek/deepseek-flash",
"state": "idle",
"provider": "deepseek",
"model": "deepseek-flash",
"context_size": 50000,
"lifecycle": {
"retries": 0,
"rips": 0,
"self_exits": 0,
"ripped_at": null,
"resurrected_at": null
},
"messages": 2,
"tokens": {
"input": 120,
"output": 45,
"total": 165
},
"logs": {
"stdout_bytes": 1234,
"stderr_bytes": 0
},
"updated_at": "2026-11-01T08:46:51Z"
}
]
}
```
In `list`, `messages` is an integer total. In `get_info`, it is an object.
### `get_info`
Get complete metadata for one agent.
| Argument | Type | Required | Default | Notes |
| ---------- | ------ | -------- | ------- | ----------- |
| `agent_id` | string | yes | — | Agent UUID. |
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"name": "deepseek/deepseek-flash",
"state": "idle",
"provider": "deepseek",
"model": "deepseek-flash",
"context_size": 50000,
"mounts": {
"write": ["/home/user/project"],
"read": ["/home/user/data"],
"denied": []
},
"cwd": "/tmp",
"session_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"pid": 4242,
"created_at": "2026-11-01T07:12:51Z",
"updated_at": "2026-11-01T08:46:51Z",
"last_activity_at": "2026-11-01T08:46:51Z",
"messages": {
"total": 2,
"user": 1,
"assistant": 1,
"tool_calls": 0
},
"tokens": {
"input": 120,
"output": 45,
"total": 165
},
"session_bytes": 149799,
"logs": {
"stdout_bytes": 40922,
"stderr_bytes": 0
},
"queue_length": 0,
"last_error": null,
"exit_code": null,
"lifecycle": {
"retries": 0,
"rips": 0,
"self_exits": 0,
"ripped_at": null,
"resurrected_at": null
},
"callback_agent_id": null,
"callback": {
"agent_id": null,
"settle_count": 0,
"notified_seq": 0,
"notified_at": null,
"error": null
},
"artifacts": {
"dir": "/home/user/.local/share/pi-mcp/agents/01a1964b-2c01-70a5-b520-78f933cdafad",
"stdout_log": "/home/user/.local/share/pi-mcp/agents/01a1964b-2c01-70a5-b520-78f933cdafad/stdout.log",
"error_log": "/home/user/.local/share/pi-mcp/agents/01a1964b-2c01-70a5-b520-78f933cdafad/errors.log",
"session": "/home/user/.local/share/pi-mcp/agents/01a1964b-2c01-70a5-b520-78f933cdafad/agent_session.jsonl",
"tmp": "/home/user/.local/share/pi-mcp/agents/01a1964b-2c01-70a5-b520-78f933cdafad/tmp"
},
"last_assistant_text": "Done. I updated the parser."
}
```
`state` is one of `active`, `idle`, `ripped`, or `exited`. `pid` is `null` when
no process is live.
### `run`
Spawn a caged pi agent and return after its idle handshake. It waits for
`send_message`.
| Argument | Type | Required | Default | Notes |
| ------------------- | -------- | -------- | ----------------------------------- | ------------------------------------------------------------------- |
| `provider` | string | no | `$PI_MCP_PROVIDER` or `deepseek` | Provider name. |
| `model` | string | no | `$PI_MCP_MODEL` or `deepseek-flash` | Model name. |
| `write` | string[] | no | `[]` | Absolute host paths mounted writable. |
| `read` | string[] | no | `[]` | Absolute host paths mounted read-only. |
| `denied` | string[] | no | `[]` | Absolute host paths explicitly denied. |
| `callback_agent_id` | string | no | `null` | Existing agent to prompt when this child settles. |
| `access_token` | string | no | `null` | Starts the child in another token group and patches its MCP config. |
| `context_size` | integer | no | `null` | Must be at least `20000`. Lowers the child's context budget. |
Response: same `AgentInfo` shape as `get_info`. A newly started agent has
`"state": "idle"` and `"last_assistant_text": null`.
### `rip`
Stop the process while keeping the record, session, logs, forwarded `/tmp`, and
spawn settings. Idempotent.
| Argument | Type | Required | Default | Notes |
| ---------- | ------ | -------- | ------- | ----------- |
| `agent_id` | string | yes | — | Agent UUID. |
Returns full `AgentInfo` plus:
```json
{
"ripped": true,
"already_ripped": false
}
```
`state` becomes `"ripped"`, `pid` becomes `null`, and `lifecycle.rips`
increments.
### `resurrect`
Restart a ripped or exited agent using its stored settings.
| Argument | Type | Required | Default | Notes |
| ---------- | ------ | -------- | ------- | --------------------------------------------------------- |
| `agent_id` | string | yes | — | Agent UUID. |
| `prompt` | string | no | `null` | If present, immediately starts a turn. Must not be empty. |
Returns full `AgentInfo` plus:
```json
{
"resurrected": true,
"prompt_sent": true,
"delivery": "resurrect_prompt",
"disposition": "started"
}
```
Without `prompt`, `prompt_sent` is `false`, and `delivery` / `disposition` are
`null`.
### `send_message`
Send a message to a live agent.
| Argument | Type | Required | Default | Notes |
| ---------- | -------------------------- | -------- | ------------- | --------------------------------------- |
| `agent_id` | string | yes | — | Agent UUID. |
| `message` | string | yes | — | Message text. |
| `mode` | `"follow_up"` or `"steer"` | no | `"follow_up"` | Used when the agent is already running. |
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"delivery": "hot_prompt",
"disposition": "started",
"state": "active"
}
```
Possible deliveries: `hot_prompt`, `hot_follow_up`, `hot_steer`.
### `remove`
Stop the agent if running, then delete its record and artifacts.
| Argument | Type | Required | Default | Notes |
| ---------- | ------ | -------- | ------- | ----------- |
| `agent_id` | string | yes | — | Agent UUID. |
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"removed": true
}
```
### `grep`
Run the host's `grep` against an agent log stream. No shell is used.
| Argument | Type | Required | Default | Notes |
| ---------- | ------------------------ | -------- | ---------- | ------------------------------------- |
| `agent_id` | string | yes | — | Agent UUID. |
| `args` | string[] | yes | — | Grep argv, without the log path. |
| `stream` | `"stdout"` or `"errors"` | no | `"stdout"` | Selects `stdout.log` or `errors.log`. |
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"stream": "stdout",
"binary": "/usr/bin/grep",
"cwd": "/home/user/.local/share/pi-mcp/agents/01a1964b-2c01-70a5-b520-78f933cdafad",
"path": "stdout.log",
"args": ["-n", "response"],
"argv": ["-n", "response", "stdout.log"],
"exit_code": 0,
"stdout": "5:{\"type\":\"response\"}\n",
"stderr": "",
"stdout_truncated": false,
"stderr_truncated": false
}
```
The log filename is appended as the final grep operand.
### `list_tmp`
List files under an agent's forwarded `/tmp`.
| Argument | Type | Required | Default | Notes |
| ------------- | ------- | -------- | ------- | --------------------------------------- |
| `agent_id` | string | yes | — | Agent UUID. |
| `path` | string | no | `""` | Relative directory under the agent tmp. |
| `recursive` | boolean | no | `true` | Recurse into subdirectories. |
| `max_entries` | integer | no | `1000` | Clamped to `1..5000`. |
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"path": ".",
"recursive": true,
"count": 3,
"truncated": false,
"entries": [
{
"path": "notes.txt",
"type": "file",
"size": 21,
"mtime": 1793522881.5025642
},
{
"path": "sub",
"type": "directory",
"size": 4096,
"mtime": 1793522881.5025642
},
{
"path": "sub/data.bin",
"type": "file",
"size": 7,
"mtime": 1793522881.5026317
},
{
"path": "link",
"type": "symlink",
"size": 8,
"mtime": 1793522881.5027,
"target": "../outside.txt"
}
]
}
```
`mtime` is a Unix timestamp in seconds. Symlinks include `target`.
### `grep_tmp`
Run the host's `grep` inside an agent's forwarded `/tmp`.
| Argument | Type | Required | Default | Notes |
| ---------- | -------- | -------- | ------- | --------------------------------------------------------------- |
| `agent_id` | string | yes | — | Agent UUID. |
| `args` | string[] | yes | — | Grep argv, with no shell. |
| `cwd` | string | no | `""` | Relative working directory under tmp. |
| `path` | string | no | `null` | Optional file relative to `cwd`; appended as the final operand. |
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"binary": "/usr/bin/grep",
"cwd": "/home/user/.local/share/pi-mcp/agents/01a1964b-2c01-70a5-b520-78f933cdafad/tmp/sub",
"path": "data.bin",
"args": ["-a", "xyz"],
"argv": ["-a", "xyz", "data.bin"],
"exit_code": 0,
"stdout": "abc\u0000xyz\n",
"stderr": "",
"stdout_truncated": false,
"stderr_truncated": false
}
```
Traversal and symlinks escaping the agent tmp are rejected.
### `read_tmp`
Read or download a file from an agent's forwarded `/tmp`.
| Argument | Type | Required | Default | Notes |
| ------------ | ------- | -------- | ------- | ------------------------------------ |
| `agent_id` | string | yes | — | Agent UUID. |
| `path` | string | yes | — | File path relative to the agent tmp. |
| `lines_from` | integer | no | `null` | 0-based start line. |
| `lines_to` | integer | no | `null` | End line, exclusive. |
| `bytes_from` | integer | no | `null` | Byte mode start offset. |
| `bytes_to` | integer | no | `null` | Byte mode end offset. |
| `max_lines` | integer | no | `null` | Maximum lines to return. |
| `max_bytes` | integer | no | `1024` | Maximum bytes; capped at 10 MiB. |
Line mode example:
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"path": "notes.txt",
"mode": "lines",
"size": 21,
"lines_from": 1,
"lines_to": 2,
"line_count": 1,
"next_line": 2,
"next_offset": 21,
"eof": true,
"max_lines": null,
"max_bytes": 1024,
"encoding": "utf-8",
"content": "beta line\n",
"truncated_regions": [],
"warnings": []
}
```
Byte mode replaces `lines_from` / `lines_to` / `next_line` with `bytes_from` /
`bytes_to`:
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"path": "sub/data.bin",
"mode": "bytes",
"size": 7,
"bytes_from": 0,
"bytes_to": 7,
"line_count": 0,
"next_offset": 7,
"eof": true,
"max_lines": null,
"max_bytes": 1024,
"encoding": "base64",
"content": "YWJjAHh5eg==",
"truncated_regions": [],
"warnings": []
}
```
Binary content is base64-encoded. Long lines over 10,000 characters are
truncated with warnings and byte regions.
### `read`
Read an agent log stream.
| Argument | Type | Required | Default | Notes |
| ------------ | ------------------------ | -------- | ---------- | ------------------------------------- |
| `agent_id` | string | yes | — | Agent UUID. |
| `stream` | `"stdout"` or `"errors"` | no | `"stdout"` | Selects `stdout.log` or `errors.log`. |
| `lines_from` | integer | no | `null` | 0-based start line. |
| `lines_to` | integer | no | `null` | End line, exclusive. |
| `bytes_from` | integer | no | `null` | Byte mode start offset. |
| `bytes_to` | integer | no | `null` | Byte mode end offset. |
| `max_lines` | integer | no | `null` | Maximum lines to return. |
| `max_bytes` | integer | no | `1024` | Maximum bytes; capped at 10 MiB. |
```json
{
"agent_id": "01a1964b-2c01-70a5-b520-78f933cdafad",
"stream": "stdout",
"path": "stdout.log",
"mode": "lines",
"size": 40922,
"lines_from": 0,
"lines_to": null,
"line_count": 1,
"next_line": 1,
"next_offset": 145,
"eof": false,
"max_lines": 1,
"max_bytes": 1024,
"encoding": "utf-8",
"content": "{\"type\":\"response\",\"id\":\"rpc-0\",\"success\":true}\n",
"truncated_regions": [],
"warnings": []
}
```
Line and byte ranges are mutually exclusive.
## Tests
```bash
make lint # Ruff + Prettier checks
make test # unit + stdio MCP round-trip
make e2e # Docker + Testcontainers end-to-end
make real # Cheap real provider checks
make expensive # Real compaction run; only when context/compaction changes
```
## Documentation
- [Design](doc/design.md) — MCP surface, RPC lifecycle, persistence,
supervision, configuration, security.
- [pi behavior](doc/pi-reference.md) — verified pi 1.4.0 process, RPC, bash,
context, compaction, and record-type behavior.
- [Sandboxing](doc/sandboxing.md) — write/read/denied mount rules and bwrap behavior.
- [Development](doc/development.md) — setup, layout, container, manual tests.
## License
MIT. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues