# MCP server

> Connect an MCP client to moatbox with an API key so an agent outside moatbox can list spaces, start runs, run commands, edit files and take snapshots.

moatbox serves a remote MCP server. An agent on your laptop or in your CI can use it to drive spaces: start agent runs, follow them, run commands, read and write files, and save or restore snapshots.

```text
https://app.moatbox.dev/api/mcp
```

The transport is Streamable HTTP. Each JSON-RPC message is one `POST`.

## Authentication

The server accepts an API key and nothing else:

```http
Authorization: Bearer mbx_…
```

A dashboard browser session does not work here. A request with no key, or a key moatbox does not know, gets `401` with `WWW-Authenticate: Bearer realm="moatbox"`. An expired key gets `401` with code `api_key_expired`.

Create the key in **Settings → API keys** (see [API keys](https://docs.moatbox.dev/api-keys.md)). Give it only what the agent needs:

- `read-only` lets the agent look and change nothing. Reading a space or its files still wakes the space when it sleeps.
- `operate` adds runs, commands and file writes in existing spaces.
- `ci` adds creating, waking and forking spaces.

Limit the key to the spaces or environments the agent should touch. A space key (`MOATBOX_SPACE_KEY` inside a space) also works and reaches only its own space.

## Tools

`tools/list` returns only the tools your key may call. A tool whose route needs an action the key lacks stays hidden. A space key, for example, never sees `create_space`.

| Tool | What it does | Key action |
|---|---|---|
| `list_spaces` | List the spaces this key can reach, with slug, status and size. | `space.read` |
| `get_space` | Show one space: status, size, server, and whether runs can start. | `space.read` |
| `create_space` | Create a space in the key's team. It starts provisioning; poll `get_space` until it is running. | `space.create` |
| `wake_space` | Wake a sleeping space. | `space.wake` |
| `sleep_space` | Put a space to sleep. Its disk stays; running agents stop. | `space.sleep` |
| `fork_space` | Copy a space, disk and all, into one or more new spaces. | `space.fork` |
| `start_run` | Give an agent (`claude`, `codex` or `opencode`) a task in a space. | `run` |
| `list_runs` | List a space's runs, newest first. | `space.read` |
| `get_run` | Show a run: status, agent, task, result. | `space.read` |
| `get_run_timeline` | Show what a run did step by step: messages, tool calls, held actions. | `space.read` |
| `send_message` | Send a follow-up message to a running agent. | `run` |
| `stop_run` | Stop a running agent. | `run` |
| `run_command` | Run a shell command and return its exit code and output. moatbox stops the command after `timeout_seconds`: 1 to 600, 30 by default. `detached` returns at once with an id; a detached command takes 0 to 86400 seconds and has no limit by default. | `exec` |
| `get_command` | Status and output of a command `run_command` started. | `exec` |
| `list_files` | List a directory. Pass `path`, such as `/workspace`. | `file.read` |
| `read_file` | Read a text file. | `file.read` |
| `write_file` | Write a text file, replacing it when it exists. | `file.write` |
| `list_pending_approvals` | List actions agents took that wait for a person's approval. | `space.read` |
| `list_snapshots` | List a space's saved snapshots. | `snapshot.read` |
| `save_snapshot` | Save the space's disk as a named snapshot. | `snapshot.write`, plus `run`, `exec` or `file.write` |
| `restore_snapshot` | Roll a space back to a snapshot. moatbox keeps the current state for a while; bring it back from the dashboard. | `snapshot.write`, plus `run`, `exec` or `file.write` |

Each tool's input schema sets `additionalProperties: false`. Tools that take a space name it with `space`, the slug that `list_spaces` shows. File paths are absolute and sit under `/workspace` or `/home/agent`, with no `..` in them.

The server marks tools with MCP annotations so your client can decide what to confirm:

- `readOnlyHint: true`: `list_spaces`, `get_space`, `list_runs`, `get_run`, `get_run_timeline`, `get_command`, `list_files`, `read_file`, `list_pending_approvals`, `list_snapshots`.
- `destructiveHint: true`: `run_command`, `write_file`, `restore_snapshot`.

The key's creator still needs the right team role. Creating and forking spaces, and saving snapshots, need an owner or admin behind the key.

## Results

A tool call returns MCP text content. moatbox calls its own [HTTP API](https://docs.moatbox.dev/api.md) for each tool and passes back the response body:

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{"type": "text", "text": "{\"spaces\":[…]}"}],
    "isError": false,
    "structuredContent": {"spaces": […]}
  }
}
```

- If the API succeeds with a JSON object, the result also carries the whole object as `structuredContent`.
- When the API answers with status 400 or above, `isError` is `true` and the text starts with the status, for example `HTTP 403: {"error":"this API key may not run","code":"api_key_action_forbidden"}`.
- A missing required argument returns `isError: true` with `<name> is required`. An argument the tool does not define returns `unknown argument <name>`.
- An unknown tool name returns JSON-RPC error `-32602`.

Each call goes through the same checks as the HTTP API. A call that changes something adds an `api_key.write` entry to the team's audit log, like any other write the key makes.

## Limits

| Limit | Value | What happens |
|---|---|---|
| Requests per key | 300 per minute | `429` with `Retry-After: 60` |
| Request body | 8 MiB | `413`, JSON-RPC error `-32600` |
| Tool output | 200 KiB of text | The text ends with `… cut at N of M bytes`; `structuredContent` is not cut |
| Output that is not valid UTF-8 | none returned | The text says how many bytes it was; copy the file with `scp` or the files API |

The routes behind most tools cap their own body at 64 KiB. `write_file` uses the streaming upload route, so its `content` can use most of the 8 MiB. The whole JSON-RPC request counts toward that limit, including the envelope and JSON escaping.

## Protocol

| Item | Behaviour |
|---|---|
| Versions through `initialize` | `2025-11-25`, `2025-06-18`, `2025-03-26` |
| Version with per-request headers | `2026-07-28` |
| Methods | `initialize`, `ping`, `tools/list`, `tools/call`, `server/discover` |
| Capabilities | `tools` only |
| Sessions | None. moatbox sends no `MCP-Session-Id`. |
| Responses | One `application/json` body per request, never an SSE stream |
| `GET` on the endpoint | `405` |
| Notifications (no `id`) | `202` with no body |
| Batches (a JSON array) | `400`, JSON-RPC error `-32600` |

`initialize` answers with the version the client asked for when it is one of the three `initialize` versions, and `2025-11-25` otherwise. After that, send `MCP-Protocol-Version` on each request. Without it moatbox assumes `2025-03-26`. An unsupported version gets `400` with JSON-RPC error `-32022` and `data.supported` listing the versions moatbox accepts.

On `2026-07-28`, each request carries its version in `params._meta["io.modelcontextprotocol/protocolVersion"]`. moatbox then checks the headers against the body: `MCP-Protocol-Version` must equal that version, `Mcp-Method` must equal the method, and on `tools/call` `Mcp-Name` must equal the tool name (plain or in `=?base64?…?=` form). A mismatch gets `400` with JSON-RPC error `-32020`.

moatbox refuses requests that a browser sends from another site (`403`).

## What the server leaves out

The MCP server covers day-to-day work in spaces. These stay in the HTTP API or the dashboard:

- deleting spaces,
- approving or denying held actions (a person decides in the dashboard; see [Approvals](https://docs.moatbox.dev/approvals.md)),
- API keys, webhooks, environments and team settings,
- SSH, the desktop and tailnet access,
- copying files between spaces, deleting snapshots, resets.

## Client setup

Keep the key out of config files: put it in an environment variable, here `MOATBOX_API_KEY`, and reference the variable.

```bash
export MOATBOX_API_KEY=mbx_…
```

### Claude Code

Add the server from the command line:

```bash
claude mcp add --transport http moatbox https://app.moatbox.dev/api/mcp \
  --header "Authorization: Bearer $MOATBOX_API_KEY"
```

Your shell expands the variable, so the key itself goes into the config. The default `local` scope keeps that config to you and the current project. To share the server through the project's `.mcp.json` without the key, write the file by hand; Claude Code expands `${VAR}` in `url` and `headers`:

```json
{
  "mcpServers": {
    "moatbox": {
      "type": "http",
      "url": "https://app.moatbox.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ${MOATBOX_API_KEY}"
      }
    }
  }
}
```

### Codex

Add the server to `~/.codex/config.toml`, or to `.codex/config.toml` in a trusted project:

```toml
[mcp_servers.moatbox]
url = "https://app.moatbox.dev/api/mcp"
bearer_token_env_var = "MOATBOX_API_KEY"
default_tools_approval_mode = "writes"
```

`bearer_token_env_var` names the variable Codex sends as the bearer token. With `writes`, Codex asks you before it calls any tool that moatbox does not mark read-only.

Codex cannot ask you when it runs with `approval_policy = "never"`, as in an unattended job, so it refuses the tools moatbox marks destructive: `run_command`, `write_file` and `restore_snapshot`. To let it call them, set `default_tools_approval_mode = "approve"` for the server. Codex then calls every moatbox tool without asking, so give that server a key with only the actions the job needs. To approve a single tool, set `tools.<tool>.approval_mode` instead.

### Cursor

Add the server to `~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project:

```json
{
  "mcpServers": {
    "moatbox": {
      "url": "https://app.moatbox.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ${env:MOATBOX_API_KEY}"
      }
    }
  }
}
```

Cursor reads `${env:MOATBOX_API_KEY}` from your environment.

### Any other client

Point a Streamable HTTP client at `https://app.moatbox.dev/api/mcp` with the `Authorization` header. You can also call the server with curl:

```bash
curl -X POST https://app.moatbox.dev/api/mcp \
  -H "Authorization: Bearer $MOATBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-11-25" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"space": "<space>", "command": "git status"}}}'
```

The `initialize` response includes server instructions that tell the agent where to start: `list_spaces` first, `start_run` to hand over a task, `get_run` and `get_run_timeline` to follow it.
