# MCP server

A zero-dependency stdio MCP server exposing every Deck API operation as a tool, with the annotations a client's approval policy keys on.

## Register it

`scripts/mandeck-mcp.mjs` is a stdio MCP server exposing the same operations as
tools, for agents that are MCP clients — Claude Code, Codex, Gemini CLI,
Cursor. A bot on a shell uses [the CLI](/docs/cli) instead.

```json
{ "mcpServers": { "mandeck": { "command": "node",
    "args": ["/path/to/mandeck-mcp.mjs"] } } }
```

A running deck serves its own copy at `GET /cli/mandeck-mcp.mjs`. It is Node
with no dependencies, and it reads the same token file as everything else.

It is dual-era: it speaks spec 2026-07-28 (stateless, per-request `_meta`,
`server/discover`) and still answers a legacy `initialize`.

## Approving tools

Consent lives in the client, not the protocol. Every tool carries annotations,
and the profile to install is *read = free, act = ask, close = only your own
panes* — and the last one is enforced by the deck (403 without `force`), not by
trust.

- Claude Code `settings.json`: `"permissions": {"allow": ["mcp__mandeck__deck_sessions",
  "mcp__mandeck__deck_screen", "mcp__mandeck__deck_messages", "mcp__mandeck__deck_jobs",
  "mcp__mandeck__deck_job_state", "mcp__mandeck__deck_job_wait", "mcp__mandeck__deck_job_report",
  "mcp__mandeck__deck_events", "mcp__mandeck__deck_agents", "mcp__mandeck__deck_health"]}` —
  everything else prompts (Claude Code does not auto-approve on annotations).
- Codex CLI `config.toml`: `[mcp_servers.mandeck] default_tools_approval_mode = "writes"`
  prompts only for tools not marked read-only.
- Gemini CLI policy: a rule on `toolAnnotations = { readOnlyHint = true }` → `allow`.

`destructiveHint` only means something when `readOnlyHint` is false; a read tool
is trivially idempotent; and this deck is one machine, so `openWorldHint` is
always false.

## The tools

`tools/list` is deterministic and follows the [route order](/docs/routes), so a
client can cache it. In the table below **read** is `readOnlyHint`,
**destructive** is `destructiveHint`, and **act** is everything else — additive,
worth an ask.

| Tool | Annotation | What it does |
|---|---|---|
| `deck_sessions` | read | List every pane on the deck: id (address), pane (stable uuid), agent, cwd, state (focused/needsYou/live) |
| `deck_screen` | read | What a pane's terminal shows right now (ANSI-stripped tail) |
| `deck_messages` | read | A pane's structured transcript (falls back to screen when the agent has no transcript reader) |
| `deck_send` | destructive | Type text into a pane. submit=true (default) presses a real Return; false types without submitting (use before slash-command menus) |
| `deck_key` | destructive | Press a control key in a pane: ctrl-c, escape, ctrl-d, up, down, enter, or a digit 0-9 (digits answer TUI pickers) |
| `deck_spawn` | act | Start an agent CLI in a new pane (slugs from deck_agents; 'shell' for a bare shell — a shell pane does NOT run the prompt, it only records it). cwd is required (no default home directory). Refuses a duplicate live pane for the same agent+cwd unless force=true (409 carries the existing session/pane/job). Returns {session, pane, job}. A spawn with a prompt opens a follow-up contract (needs-review is not job done) |
| `deck_jobs` | read | Tasked-spawn jobs, newest first: id, state (running\|waiting-you\|done\|failed), session, pane, agent, cwd, live |
| `deck_job_state` | read | Job lifecycle state (running \| waiting-you \| done \| failed). Distinct from pane live/needsYou. done/failed only after an explicit protocol signal, never from silence or needs-review |
| `deck_job_wait` | read | Poll a job until done, failed, waiting-you, or timeout. Does not settle the follow-up contract. Returns the job JSON plus a wait field (done\|failed\|waiting-you\|timeout) |
| `deck_job_report` | read | Transcript (or screen fallback) for a job, for a bot to paste. Reading it settles the follow-up contract |
| `deck_job_done` | act | Explicitly finish a job as done — with the [mandeck job-done] line, the only way a job ends. Idempotent; 409 if the job already failed |
| `deck_job_fail` | act | Explicitly finish a job as failed. Idempotent; 409 if the job is already done |
| `deck_close` | destructive | Close a pane. Refused (403, with the pane uuid) when the pane was not spawned through the API — the human opened it — unless force=true |
| `deck_focus` | act | Bring a pane to the human's screen |
| `deck_agents` | read | Agent CLIs installed on this Mac (the only valid spawn slugs) |
| `deck_events` | read | Append-only needs-you/spawned/needs-review/done/failed event log; poll with the last seq instead of diffing sessions. done/failed are job-terminal, not follow-up settle |
| `deck_tabs` | read | The deck's tabs (workspaces): index, title, pane count |
| `deck_history` | read | Past sessions on this deck, searchable by title/cwd/agent |
| `deck_resume` | act | Reopen a past session (ids from deck_history) in a new pane |
| `deck_projects` | read | Recent project folders on this Mac |
| `deck_accounts` | read | Provider accounts this deck can spend |
| `deck_create_account` | act | Ensure a provider account exists (idempotent) |
| `deck_health` | read | Is a deck here? Pane count + TCC permission state (no auth needed) |

`deck_job_wait` polls the job only, so it does not settle the spawn's follow-up
contract; `deck_job_report` does, because that is a real check. See
[Jobs & hand-offs](/docs/jobs-and-hand-offs).
