Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The MCP server

A client that has no shell reaches ank through ank mcp, a verb of the one executable every route installs (ADR-1ea31c2f3c5a). There is no second file to fetch, sign or discover: what the CLI dispatches is what the surface serves, because they are the same file. It speaks JSON-RPC over stdio and the client spawns it, which means it is configured rather than started.

Configuring a client

Three configurations, and each of them is pasted rather than derived.

Claude Code, one line, which writes the entry for you:

claude mcp add ank -- ank mcp --repo /path/to/your/repo

or .mcp.json at the root of the repository, which is the form that travels with the tree and reaches everyone who clones it:

{
  "mcpServers": {
    "ank": {
      "command": "ank",
      "args": ["mcp", "--repo", "/path/to/your/repo"]
    }
  }
}

Claude Desktop, in claude_desktop_config.json (~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on Windows), which has no project directory of its own and so has nothing else to go on:

{
  "mcpServers": {
    "ank": {
      "command": "ank",
      "args": ["mcp", "--repo", "/path/to/your/repo"]
    }
  }
}

Cursor, in .cursor/mcp.json beside the repository, or ~/.cursor/mcp.json for every project at once:

{
  "mcpServers": {
    "ank": {
      "command": "ank",
      "args": ["mcp", "--repo", "/path/to/your/repo"]
    }
  }
}

command is ank and mcp is the first argument, in all three. If you have a configuration written against ank-mcp, that is the one line to change: releases up to 0.6.0 placed a second executable by that name and no route places one any more, so a client still naming it gets command not found rather than a wrong answer.

--repo is written out in all three, and that is the point of showing them. A client spawns the server in whatever directory it happens to be in, and with no --repo the server takes that directory. The failure mode is not an error: it is a process quietly speaking for a corpus nobody meant, or for none. The configuration is also where it is named rather than something a call may override; a call that tries is refused, below. A path with no corpus under it is refused before any client is listening, rather than after, so it reaches a person rather than a log:

$ ank mcp --repo /tmp
error[1]: no .ank/ found from /tmp
  -> ank init

Several repositories, one server

Several repositories do not need several servers. --repo names the corpus a call naming none of its own goes to; every other corpus that server may reach is declared once, outside every repository, and the configuration above does not change by a character. The declaration is written through the CLI, keyed on the repository identity of the corpus and never on a path:

$ ank config --user corpora.bccc32d77d8a9a329f772f789dc5fb1054259d70 /srv/back
corpora.bccc32d77d8a9a329f772f789dc5fb1054259d70 /srv/back

What that writes is corpora.yml (ADR-96174f1ac2b7), in the reader’s configuration directory (Environment variables says where that is):

schema: 1
corpora:
  bccc32d77d8a9a329f772f789dc5fb1054259d70: /srv/back

The identity is the root commit, which ank status --json prints under "corpus", so run that in the repository you want to declare and paste what it gives you.

What the surface is

Four properties of it are load-bearing, and none of them is visible from a tool list.

Every verb COMMANDS carries, generated from that table. Not a curated subset, under any protocol (ADR-fd98f4bc6dea). It is the same table ank help --json is generated from, walked: the summary becomes the tool description, the refusals and their exit codes are written into it so a client can read what a call will refuse before making it, and the flags become the input schema. One tool per verb, whatever the table carries, named ank_<verb> because a bare context collides with every other server a client has loaded and ank context is not a legal tool name. Positionals arrive as arguments, an array of strings, exactly as they sit on the command line; flags arrive under their own names with the leading dashes stripped. What a call gets back is the document --json returns, with the exit code beside it. Nothing in the server names a verb, so the two surfaces cannot disagree about what exists.

One process may speak for several corpora, and never for a merged one. --repo is resolved once, at startup, and that corpus is where a call naming none of its own goes, so a client that never passes the argument sees exactly what it saw before the argument existed. Every tool also carries an optional corpus argument (ADR-fd98f4bc6dea), whose value is the repository identity of ADR-621a7fd96ce1 – the root commit, never a path. One server, addressed at one corpus at startup, answering out of another the reader declared:

--> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ank_find","arguments":{"arguments":["--status","open"],"corpus":"bccc32d77d8a9a329f772f789dc5fb1054259d70"}}}
<-- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"contract\":1,\"corpus\":\"bccc32d77d8a9a329f772f789dc5fb1054259d70\",\"total\":1,\"shown\":1,\"hidden\":0,\"results\":[{\"id\":\"TASK-6a3615347674\",\"kind\":\"task\",\"status\":\"open\",\"state\":\"open\",\"title\":\"The back answers a query\",\"created\":\"2026-08-26T00:22:04Z\",\"archived\":false}]}"}],"isError":false,"exitCode":0}}

That permits multiplexing. It still forbids merging, and telling those two apart is the whole of the decision, so it is worth being exact about which one you are building. Every call becomes ank --repo <one corpus> <verb> --json, one corpus at a time. There is no merged claim space, no claim held on a client’s behalf, and no arbitration across clones, because refs/ank/* is per repository and cannot carry one – the same ban federation gets (ADR-a1de673043b4), carried into the multi-corpus clause in the same words. Two claims taken through one server land in refs/ank/claims of two repositories, and neither corpus carries a word about the other’s task. So a board over four repositories is one server and four corpora addressed on their own, presented together by whatever sits above them; it is not four claim spaces made into one, and a client that shows them as one list must not arbitrate over that list. What a multi-corpus server does acquire is one identity holding a lease in several corpora at once, and nothing beyond it.

The reachable set is declared, and nothing is discovered: the startup corpus plus whatever corpora.yml declares. A caller cannot name a corpus by path, so there is no spelling of “every corpus on this machine”; and an identity nobody declared is refused by name, with nothing spawned and no falling back to the corpus the client did not ask for:

<-- {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"error[9]: no corpus is declared under 0000000000000000000000000000000000000000, and this server reaches no corpus nobody declared\n  -> ank config --user corpora.0000000000000000000000000000000000000000 <path>"}],"isError":true,"exitCode":9,"stderr":"error[9]: no corpus is declared under 0000000000000000000000000000000000000000, and this server reaches no corpus nobody declared\n  -> ank config --user corpora.0000000000000000000000000000000000000000 <path>"}}
<-- {"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"error[9]: '/srv/back' is not a repository identity\n  -> a corpus is named by its root commit, never a path, a remote or a slug: ank status --json prints it under \"corpus\""}],"isError":true,"exitCode":9,"stderr":"error[9]: '/srv/back' is not a repository identity\n  -> a corpus is named by its root commit, never a path, a remote or a slug: ank status --json prints it under \"corpus\""}}

Both are 9, and 9 is the right code for both: what is missing is a declaration in the reader’s configuration, not anything in either corpus.

The three flags the server keeps for itself stay refused. A call that passes --repo, --json or --quiet is turned away by name rather than being allowed to contradict the process it is talking to, and --repo is turned away naming the argument a caller reaches for instead:

<-- {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"--repo belongs to the server: name a corpus with the corpus argument, by the identity ank status --json prints, never by a path"}}

Nothing is hidden by that and nothing is curated: every verb takes exactly the arguments the table gives it, plus the one argument that says which corpus it runs in.

A refusal is the CLI’s refusal, and it carries the CLI’s exit code. The surface spawns ank; it does not link it. So a refusal on state is not re-derived here, it is inherited, hint and all, and it comes back as a result rather than as a protocol error, because the request was well formed and the answer is no:

<-- {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"error[2]: entity not found: TASK-9999\n  -> ank find TASK-9999"}],"isError":true,"exitCode":2,"stderr":"error[2]: entity not found: TASK-9999\n  -> ank find TASK-9999"}}

exitCode is present on every call including a successful one, so a client that branches on it never has to tell absence from zero; stderr is carried separately for the reason warnings live there in the first place. The two error channels stay apart: a JSON-RPC error means the request was wrong, a result with isError means the corpus said no. A client that conflates them reports its own bug as a state of your repository.

No claim is taken that the CLI would not have taken in that clone. Every claim goes to refs/ank/claims/<id> in that repository – the corpus the call named, or the startup one where it named none – arbitrated by the same compare-and-swap against the same remote. The server holds no claim on a client’s behalf, renews none for anybody, and pools no clients under one identity: one stdio server serves one client, so one process is one caller. It writes under a typed process identity, ank-mcp/<version>, unless $ANK_AGENT names one, so a deployment that already names its agents keeps naming them.

accept is a tool here like every other verb, because a generated surface curates nothing out. Being reachable over a protocol changes nothing about it: it still refuses off the default branch, with no way around it.

What a document, an exit code and a warning mean is the same here as on the command line, and the machine surface is where it is said.