MCP integration#
swarmops mcp serves a store's operations to an agent as an MCP server on stdin and stdout. An
agent harness that speaks the Model Context Protocol, such as Claude Code, then calls each
operation as a tool, and gets back the same JSON envelope the CLI prints. This page covers
registering the server, the protocol exchange a client has with it, how identity works, every
tool with its matching command, result sizes and what goes wrong.
Every exchange below is from a real run of swarmops 0.1.0 against a scratch store at
/tmp/dev-guide/acme. Where a line is long, the command pipes it through jq to keep the part
that matters, and the filter is shown; … marks text cut by hand.
- Register the server with Claude Code
- Any stdio MCP client
- Protocol versions
- Identity: one per agent process
- The tools
- Landing through MCP
- Result size limits
- Failure modes
Register the server with Claude Code#
One agent: claude mcp add#
For a single agent, register the server with its identity in the server's environment:
claude mcp add swarmops -e SWARMOPS_AGENT=agent/worker-1 -- swarmops mcp --repo /tmp/dev-guide/acme
claude mcp list | grep swarmopsAdded stdio MCP server swarmops with command: swarmops mcp --repo /tmp/dev-guide/acme to local config
File modified: …/.claude.json [project: /tmp/dev-guide/local]
swarmops: swarmops mcp --repo /tmp/dev-guide/acme - ✔ ConnectedThat is Claude Code's local scope: private to you, in the directory you ran it in
(claude mcp remove swarmops -s local takes it out again).
Every agent of a project: .mcp.json#
For a project that several agent sessions open, register the server once at the project's root.
claude mcp add --scope project writes the .mcp.json for you. Single quotes keep your shell from
expanding ${SWARMOPS_AGENT:-}, so that Claude Code expands it later, in each session:
claude mcp add --scope project swarmops -e 'SWARMOPS_AGENT=${SWARMOPS_AGENT:-}' -- swarmops mcp --repo /tmp/dev-guide/acme
cat .mcp.json
SWARMOPS_AGENT=agent/worker-1 claude mcp list | grep swarmopsAdded stdio MCP server swarmops with command: swarmops mcp --repo /tmp/dev-guide/acme to project config
File modified: /tmp/dev-guide/proj/.mcp.json
{
"mcpServers": {
"swarmops": {
"type": "stdio",
"command": "swarmops",
"args": [
"mcp",
"--repo",
"/tmp/dev-guide/acme"
],
"env": {
"SWARMOPS_AGENT": "${SWARMOPS_AGENT:-}"
}
}
}
}swarmops: swarmops mcp --repo /tmp/dev-guide/acme - ⏸ Pending approval (run `claude` to approve)Claude Code asks once, in each project, to approve the servers a .mcp.json names; until then
claude mcp list shows ⏸ Pending approval. Three details of the entry matter:
${SWARMOPS_AGENT:-}, not one--as. The file is shared by every session that opens the project, so each session's identity comes from its ownSWARMOPS_AGENT. The empty default is deliberate: Claude Code passes an unset${SWARMOPS_AGENT}on as that very text, which the server refuses as an invalid agent name (E_INVALID_ARG), while an empty value is no identity, so a write getsE_IDENTITY_REQUIREDand reads still work.swarmops, notso. On some machine the short name may be missing (cargo install --bin swarmopsinstalls the long one alone) or belong to another program.- An absolute store path. The server runs in a working directory the agent cannot see.
Any stdio MCP client#
The transport is JSON-RPC 2.0 over stdio, one message per line. The server reads a message, answers it on stdout if it is a request, and reads the next: one at a time, in order. stdout carries nothing but answers. It exits 0 when stdin ends.
A session, start to finish#
A client that opens with initialize (the 2025-11-25 handshake) lists the tools, reads a file and
commits a change. The messages, one per line:
cat session.jsonl{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"example","version":"0.0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"rev":"main","path":"src/lib.rs","max_bytes":32768}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"commit","arguments":{"message":"shout","changes":[{"path":"src/lib.rs","op":"put","content":{"inline":"pub fn greet() -> &'static str {\n \"HELLO\"\n}\n"}}]}}}SWARMOPS_AGENT=agent/worker-1 swarmops mcp --repo /tmp/dev-guide/acme < session.jsonl > answers.jsonl
echo "rc=$?"rc=0Four answers, one per request; the notification gets none.
initialize. The server answers at the version asked for, with its identity and its
instructions for the model (cut here; the full text tells the model how to use the tools: full
ids for compare-and-swap, land to publish, small pages):
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"swarmops","title":"SwarmOps","version":"0.1.0"},"instructions":"SwarmOps is source control for coding-agent swarms: many agents commit to one store at once. …"}}tools/list. One page of 18 tools. Each entry holds name, title, description,
inputSchema and annotations; read_file alone also carries _meta:
sed -n 2p answers.jsonl | jq -c '.result.tools[] | {name, title, annotations} + (if ._meta then {_meta} else {} end)'{"name":"repo_info","title":"Describe the store","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"resolve_rev","title":"Resolve a revision","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"read_file","title":"Read a file","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true},"_meta":{"anthropic/maxResultSizeChars":200000}}
{"name":"read_tree","title":"List a directory","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"show","title":"Show a changeset","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"log","title":"List history","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"path_history","title":"List a path's history","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"diff","title":"Diff two revisions","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"op_log","title":"List operations","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"ref_list","title":"List refs","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"ref_set","title":"Move a ref","annotations":{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}}
{"name":"ref_delete","title":"Delete a ref","annotations":{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}}
{"name":"undo","title":"Undo an operation","annotations":{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}}
{"name":"commit","title":"Commit a changeset","annotations":{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}}
{"name":"land","title":"Land a change","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false}}
{"name":"blob_put","title":"Store a blob","annotations":{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}}
{"name":"blob_has","title":"Find stored blobs","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}
{"name":"verify","title":"Verify the store","annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true}}A tool's inputSchema is its request type's JSON Schema, as swarmops schema <Type> prints it,
with every definition it refers to written in place. It is kept a plain object (only $schema,
title, description, type, properties and required at its top), since the Claude API
refuses a tool whose schema has a combinator there. A rule across fields that the request type
states with a top-level oneOf (exactly one of ref_set's expected_old and create) is left
to the description and to the server's own check. read_file's, cut to its keys:
sed -n 2p answers.jsonl | jq -c '.result.tools[] | select(.name == "read_file") | .inputSchema | {title, required, properties: (.properties | keys)}'{"title":"ReadFileReq","required":["rev","path"],"properties":["lines","max_bytes","offset","path","rev"]}tools/call. The result holds the envelope twice: as structuredContent, and as the text of
the one content block. isError is true exactly when the envelope's ok is false:
sed -n 3p answers.jsonl{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"ok\":true,\"op\":\"read_file\",\"api\":1,\"result\":{\"blob\":\"2cda0801d989cfda6046cb35010ee0d591257324bf4d95d5543b1f7ac41d0463\",\"size\":47,\"kind\":\"file\",\"encoding\":\"utf8\",\"content\":\"pub fn greet() -> &'static str {\\n \\\"hello\\\"\\n}\\n\",\"truncated\":false},\"meta\":{\"repo\":\"/tmp/dev-guide/acme\",\"elapsed_ms\":0,\"resolved\":{\"main\":\"bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118\"},\"auth\":\"trust\"}}"}],"structuredContent":{"ok":true,"op":"read_file","api":1,"result":{"blob":"2cda0801d989cfda6046cb35010ee0d591257324bf4d95d5543b1f7ac41d0463","size":47,"kind":"file","encoding":"utf8","content":"pub fn greet() -> &'static str {\n \"hello\"\n}\n","truncated":false},"meta":{"repo":"/tmp/dev-guide/acme","elapsed_ms":0,"resolved":{"main":"bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118"},"auth":"trust"}},"isError":false}}The commit, recorded for the server's agent, agent/worker-1. It names no advance, so it moves
no ref; the next step is a land (Landing through MCP):
sed -n 4p answers.jsonl | jq -c '.result.structuredContent.result'{"changeset":"0e5171a021aabc37bf6b92efa555514c389d582c32542f16ae88cd43873f60c0","parent":"bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118","root":"00842161e3ab9a26a8a0d593193afaf10915f9fcf570b04c7a088c1b3cd7209d","files_changed":1,"blobs_written":1,"replayed":false}Methods#
| Method | Served |
|---|---|
initialize | Opens a session at 2025-11-25 or 2025-06-18; a request that names 2026-07-28 gets -32601. |
notifications/initialized, other notifications | No answer. A notifications/cancelled finds nothing to cancel: each request is answered before the next is read. |
ping | {}, also before initialize; not in 2026-07-28 (-32601). |
server/discover | 2026-07-28 only: the versions served, capabilities and instructions. |
tools/list | One page; a cursor is -32602. |
tools/call | name and arguments, the operation's request. |
Protocol versions#
The server speaks three revisions of MCP:
| Version | How a client opens it |
|---|---|
| 2026-07-28 | No handshake: each request names the version in params._meta, with the client's capabilities. |
| 2025-11-25 | initialize; the version given to a client that asks for one the handshake does not serve. |
| 2025-06-18 | initialize; the first revision with structuredContent and outputSchema. |
Both handshake revisions get the same tools and results. 2025-03-26 and older are not served.
A 2026-07-28 client sends each request on its own. Results carry resultType: "complete" and the
server's identity in _meta; server/discover and tools/list say a client may keep them an
hour (ttlMs), since they change only with the binary. A version the server does not serve gets
-32022 with the list it does, and a request that names no version before any initialize gets
-32602 (the content block, the envelope's second copy, is cut by the jq filter):
cat s3.jsonl
SWARMOPS_AGENT=agent/worker-1 swarmops mcp --repo /tmp/dev-guide/acme < s3.jsonl | jq -c 'if .result.structuredContent then (.result.content = "…") else . end | if .result.instructions then (.result.instructions = "…") else . end'{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}},"name":"resolve_rev","arguments":{"rev":"main"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2027-01-01","io.modelcontextprotocol/clientCapabilities":{}}}}
{"jsonrpc":"2.0","id":4,"method":"tools/list"}
{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18"],"capabilities":{"tools":{}},"instructions":"…","ttlMs":3600000,"cacheScope":"public","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"swarmops","title":"SwarmOps","version":"0.1.0"}}}}
{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":"…","structuredContent":{"ok":true,"op":"resolve_rev","api":1,"result":{"id":"bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118","kind":"ref"},"meta":{"repo":"/tmp/dev-guide/acme","elapsed_ms":0,"resolved":{"main":"bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118"},"auth":"trust"}},"isError":false,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"swarmops","title":"SwarmOps","version":"0.1.0"}}}}
{"jsonrpc":"2.0","id":3,"error":{"code":-32022,"message":"Unsupported protocol version: 2027-01-01; this server serves 2026-07-28 in params._meta, and 2025-11-25 and 2025-06-18 through initialize","data":{"requested":"2027-01-01","supported":["2026-07-28","2025-11-25","2025-06-18"]}}}
{"jsonrpc":"2.0","id":4,"error":{"code":-32602,"message":"Invalid params: tools/list names no protocol version in params._meta (io.modelcontextprotocol/protocolVersion), and no initialize has opened a session"}}Identity: one per agent process#
The store and the identity belong to the server, never to a call:
- the store is
--repo, elseSWARMOPS_REPO, opened anew for each call, so a call sees what other agents committed meanwhile; - the identity is
--as, elseSWARMOPS_AGENT(<family>/<instance>), withSWARMOPS_RUN_IDnaming the agent's run if it has one.
No tool argument names another identity: every commit, land and ref move is recorded for the
server's agent. Reads need no identity; without one, every write fails with
E_IDENTITY_REQUIRED (exit 5 in the envelope).
Claude Code starts one server for a session, and the session's subagents share it, so every write
they make is recorded for the session's agent. An agent that needs an identity of its own runs as
its own Claude Code process, started with its own SWARMOPS_AGENT in its environment: with the
.mcp.json above, two sessions of the same project started as agent/worker-1 and
agent/worker-2 get one server each, each recording its own agent.
Give every agent process its own instance name, and yourself one of your own, such as
operator/ada, so that log, show and op log say who did what.
The tools#
One tool per operation, named as the envelope's op is, in tools/list's order. Its arguments are
the CLI command's request; swarmops schema <Request> prints the type.
| Tool | CLI command | Request type | Changes the store |
|---|---|---|---|
repo_info | swarmops info | InfoReq | no |
resolve_rev | swarmops resolve REV | ResolveRevReq | no |
read_file | swarmops cat REV:PATH | ReadFileReq | no |
read_tree | swarmops ls REV[:PATH] | ReadTreeReq | no |
show | swarmops show REV | ShowReq | no |
log | swarmops log [REV] | LogReq | no |
path_history | swarmops history REV:PATH | PathHistoryReq | no |
diff | swarmops diff FROM TO | DiffReq | no |
op_log | swarmops op log | OpLogReq | no |
ref_list | swarmops ref list | RefListReq | no |
ref_set | swarmops ref set NAME REV | RefSetReq | moves a ref |
ref_delete | swarmops ref delete NAME | RefDeleteReq | moves a ref |
undo | swarmops op undo OP | UndoReq | moves refs |
commit | swarmops commit | CommitReq | adds a changeset; with advance, moves a ref |
land | swarmops land HEAD | LandReq | adds a changeset and moves the target |
blob_put | swarmops blob put | BlobPutReq | adds a blob |
blob_has | swarmops blob has | BlobHasReq | no |
verify | swarmops verify | VerifyReq | no |
init, whoami, schema, git import, git export, derive and mcp have no tool: an
operator or a script runs them as commands. A verify that finds problems is E_STORE_CORRUPT,
as swarmops verify is.
Rules the server adds to the request types, each refused with E_INVALID_ARG:
- Full ids for compare-and-swap.
ref_set's andref_delete'sexpected_old, acommit'sparentwhen it hasadvance, andland'sresolutions.againstmust be the full 64-hex changeset id you read. A ref name or a relative revision such asmain~1is read when the call runs, so the compare-and-swap would protect nothing; an id prefix stops resolving once another changeset shares it. The CLI's--expectstill takes any revision. - Absolute, regular files for
{"file": <path>}. Incommit,blob_putandland's resolutions the server reads the file itself, as the user it runs as, so a relative path, the server's own stdin or stdout, and anything that is not a regular file (a FIFO, a socket, a device, a directory) are refused. An agent can store any file that user can read, also one its own sandbox keeps from it (SECURITY.md).
Landing through MCP#
To publish, an agent commits on the head it read, then lands. Here agent/worker-2 moved main
after agent/worker-1 read it, changing the same line as worker-1's commit 0e5171a0… from the
session above:
swarmops --as agent/worker-2 commit -m "greet the swarm" --changes rival.json --parent bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118 --advance main | jq -c '.result.advanced'{"name":"main","old":"bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118","new":"3acdfd7888b83ed5af6f578f84928953e979de8fa0438e19e565fc6469f48c82","already_applied":false}Worker-1 lands its commit with an idempotency_key. The land meets the conflict, records nothing,
and returns E_CONFLICT with the report in details.report:
cat land.jsonl
SWARMOPS_AGENT=agent/worker-1 swarmops mcp --repo /tmp/dev-guide/acme < land.jsonl | sed 1d > land1.jsonl
jq -c '{isError: .result.isError, error: (.result.structuredContent.error | {code, retryable, exit})}' land1.jsonl
jq '.result.structuredContent.error.details.report | {theirs, files: [.files[] | {path, theirs_agent, hunks}]}' land1.jsonl{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"example","version":"0.0.1"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"land","arguments":{"head":"0e5171a021aabc37bf6b92efa555514c389d582c32542f16ae88cd43873f60c0","idempotency_key":"land-shout-1"}}}
{"isError":true,"error":{"code":"E_CONFLICT","retryable":false,"exit":3}}
{
"theirs": "3acdfd7888b83ed5af6f578f84928953e979de8fa0438e19e565fc6469f48c82",
"files": [
{
"path": "src/lib.rs",
"theirs_agent": "agent/worker-2",
"hunks": [
{
"base": {
"first": 2,
"last": 2,
"text": " \"hello\"\n"
},
"id": 0,
"ours": {
"first": 2,
"last": 2,
"text": " \"HELLO\"\n"
},
"theirs": {
"first": 2,
"last": 2,
"text": " \"hello, swarm\"\n"
}
}
]
}
]
}It resolves the file against the report's theirs, by its full id, and lands again with a new key.
The same call sent a second time replays the first (status: replayed) and writes nothing:
cat resolve.jsonl
SWARMOPS_AGENT=agent/worker-1 swarmops mcp --repo /tmp/dev-guide/acme < resolve.jsonl | sed 1d | jq -c '{id, isError: .result.isError, result: (.result.structuredContent.result | {status, landed, rebased_onto, attempts})}'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"example","version":"0.0.1"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"land","arguments":{"head":"0e5171a021aabc37bf6b92efa555514c389d582c32542f16ae88cd43873f60c0","idempotency_key":"land-shout-2","resolutions":{"against":"3acdfd7888b83ed5af6f578f84928953e979de8fa0438e19e565fc6469f48c82","files":{"src/lib.rs":{"content":{"inline":"pub fn greet() -> &'static str {\n \"HELLO, SWARM\"\n}\n"}}}}}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"land","arguments":{"head":"0e5171a021aabc37bf6b92efa555514c389d582c32542f16ae88cd43873f60c0","idempotency_key":"land-shout-2","resolutions":{"against":"3acdfd7888b83ed5af6f578f84928953e979de8fa0438e19e565fc6469f48c82","files":{"src/lib.rs":{"content":{"inline":"pub fn greet() -> &'static str {\n \"HELLO, SWARM\"\n}\n"}}}}}}}
{"id":2,"isError":false,"result":{"status":"landed","landed":"e7046994727e92138a6f40edbb9f67b65c7ab8c38d4a0b4076ac9273b834c844","rebased_onto":"3acdfd7888b83ed5af6f578f84928953e979de8fa0438e19e565fc6469f48c82","attempts":1}}
{"id":3,"isError":false,"result":{"status":"replayed","landed":"e7046994727e92138a6f40edbb9f67b65c7ab8c38d4a0b4076ac9273b834c844","rebased_onto":"3acdfd7888b83ed5af6f578f84928953e979de8fa0438e19e565fc6469f48c82","attempts":1}}A resolution is {"take": "ours"}, {"take": "theirs"}, {"content": CONTENT} (with an optional
mode) or {"delete": true}; it decides its path and every path below it. A path the target
changed again since against comes back in a new report's stale: read the new theirs, and
resolve against it.
There are no workspaces yet (M5): an agent that must build or test its change before it lands it needs the files on disk some other way; Operations: building and testing a change gives the stopgap.
Result size limits#
Clients limit long tool results. Claude Code saves a successful one over 25,000 tokens by default, or over 50,000 characters whatever its token count, to a file and gives the agent its path instead; it cuts only the text of an error. Each result holds its envelope twice, so ask for little:
| Tool | Ask for | Page on with |
|---|---|---|
read_file | max_bytes of 32768 or less (less for a binary file, sent as base64) | offset = the result's next_offset |
read_tree | limit of 100 or less | cursor |
log, path_history | limit of 100 or less | cursor |
diff | limit of 100 or less; max_bytes of 32768 or less for unified | cursor |
show | max_changes of 100 or less | |
op_log | limit of 20 or less | before |
The tools' descriptions say the same, so the model sees it; the operations' own defaults (such as
read_file's max_bytes of 1 MiB) stay as they are. read_file alone raises Claude Code's limit
for itself: its tools/list entry carries _meta: {"anthropic/maxResultSizeChars": 200000}, so
Claude Code keeps a read_file result of up to 200,000 characters in the conversation.
A file made of control characters reads back far larger than its size, since JSON spells most of them in six bytes each; known limitations has the numbers.
Failure modes#
Three layers fail differently: the server can refuse to start, a message can be a protocol error, and an operation can fail inside a successful tool call.
The server does not start#
A command line that parses as swarmops mcp but is refused starts no server. Its envelope goes to
stderr, since stdout belongs to the protocol, and the process exits with the envelope's code:
swarmops mcp --frob </dev/null >out.txt 2>err.txt; echo "exit=$?"
wc -c < out.txt
cat err.txtexit=2
0
{"ok":false,"op":"mcp","api":1,"error":{"code":"E_INVALID_ARG","message":"unexpected argument '--frob' found","retryable":false,"exit":2,"details":{},"hint":"Usage: swarmops mcp [OPTIONS]\nFor more information, try '--help'."},"meta":{"elapsed_ms":1,"resolved":{},"auth":"trust"}}A usage error before the word mcp (swarmops --frob mcp) is an ordinary command error, printed
on stdout with exit code 2, and starts no server either.
Protocol errors#
A message the server cannot serve gets a JSON-RPC error, and the server reads on:
| Code | For |
|---|---|
-32700 | A line that is not JSON, or not UTF-8. |
-32600 | A batch or anything else that is not one object; a bad jsonrpc or id; a line longer than 64 MiB. |
-32601 | A method the session's revision does not have. |
-32602 | Bad params; no version before initialize; an unknown tool name; arguments that are not an object; a tools/list cursor. |
-32603 | A panic outside an operation (a bug). |
-32022 | A protocol version the server does not serve; data.supported lists the ones it does. |
printf '%s\n' 'not json' | swarmops mcp; echo "exit=$?"{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error: expected ident at line 1 column 2"}}
exit=0Failed operations#
An operation that fails is a successful tool call whose result has isError: true and the
envelope's error: a stable E_* code, retryable, the CLI's exit, machine-readable details
and a hint, often the command (and so the tool) to run next. In this session the server has no
identity:
cat s2.jsonl
env -u SWARMOPS_AGENT swarmops mcp --repo /tmp/dev-guide/acme < s2.jsonl | sed 1d | jq -c 'if .result.structuredContent then {id, structuredContent: .result.structuredContent, isError: .result.isError} else . end'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"example","version":"0.0.1"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ref_set","arguments":{"name":"main","new":"0e5171a021aabc37bf6b92efa555514c389d582c32542f16ae88cd43873f60c0","expected_old":"main"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ref_set","arguments":{"name":"main","new":"0e5171a021aabc37bf6b92efa555514c389d582c32542f16ae88cd43873f60c0","expected_old":"bae3bcec1c5a0b70695b8baae3d38c34de91b270fa788268c696ed233a342118"}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"git_import","arguments":{}}}
{"jsonrpc":"2.0","id":5,"method":"ping"}
{"id":2,"structuredContent":{"ok":false,"op":"ref_set","api":1,"error":{"code":"E_INVALID_ARG","message":"expected_old \"main\" is not a full changeset id, which a compare-and-swap needs: a ref name or a relative revision is read when the call runs instead of pinning the id you read (main itself always matches, so the compare-and-swap would protect nothing, and main~1 never matches), and an id prefix stops resolving once another changeset shares it; pass the 64-hex id the ref held when you read it, from ref_list, show, log or a commit's result","retryable":false,"exit":2,"details":{"expected_old":"main","name":"main"},"hint":"swarmops ref list"},"meta":{"repo":"/tmp/dev-guide/acme","elapsed_ms":0,"resolved":{},"auth":"trust"}},"isError":true}
{"id":3,"structuredContent":{"ok":false,"op":"ref_set","api":1,"error":{"code":"E_IDENTITY_REQUIRED","message":"this operation changes the store, so it needs the caller's identity","retryable":false,"exit":5,"details":{},"hint":"pass --as <family>/<instance> or set SWARMOPS_AGENT, e.g. --as agent/worker-1"},"meta":{"repo":"/tmp/dev-guide/acme","elapsed_ms":0,"resolved":{},"auth":"trust"}},"isError":true}
{"jsonrpc":"2.0","id":4,"error":{"code":-32602,"message":"Unknown tool: git_import"}}
{"jsonrpc":"2.0","id":5,"result":{}}Arguments that do not fit the request type are E_INVALID_ARG too, with a hint that names the
schema. A store that is not there fails each call with E_NOT_A_REPO, and the server serves on.
A relative {"file": …} path is refused before anything is read:
swarmops mcp < nostore.jsonl | sed 1d | jq -c '.result.structuredContent.error | {code, exit, hint}'
SWARMOPS_AGENT=agent/worker-1 swarmops mcp --repo /tmp/dev-guide/acme < blob.jsonl | sed 1d | jq -c '.result.structuredContent.error | {code, message}'{"code":"E_NOT_A_REPO","exit":6,"hint":"pass --repo <store> or set SWARMOPS_REPO; swarmops init <dir> creates a store"}
{"code":"E_INVALID_ARG","message":"the client file \"notes.json\" is a relative path, which the server would read from its own working directory, not yours: name a regular file by its absolute path, or send the bytes as inline or base64 content"}The first server has neither --repo nor SWARMOPS_REPO and is asked for repo_info; the
second is asked for a blob_put of {"file": "notes.json"}. Each file holds an initialize
line, then that one tools/call.
What to do with each code is the same as at the CLI: CLI scripting gives the retry rules, and its Exit codes the exit codes.
The server exits#
The store runs in the server's process, so the server ends with it on any of the store's four documented aborts: the process aborts with SIGABRT, saying why on stderr, and the client sees the server exit and has to start it again. The server also exits 1 if reading stdin or writing stdout fails. The four aborts, the first three of which any commit or ref move can meet:
- a failed journal write or sync whose cut-back fails too;
- a cut-back that fails while the repair before a write writes unlisted frames afresh;
- frames that repair writes afresh which land anywhere but where they were and cannot be cut back;
- an
initthat cannot take itsFORMATback, which no tool runs.
Seeing what agents did#
The server keeps none of its answers. swarmops op log lists the writes that succeeded; for the
calls themselves, read the client's transcript, or copy the server's answers to a file with the
wrapper in Operations: where the envelopes are.
Reading and repairing history
This guide shows how to read a store's history without a checkout, and how to take back a ref move that should not have happened. It names revisions, reads…
Parallel coding agents on one repository
Run several coding agents, such as Claude Code sessions, on one codebase and they collide in the working tree, in git's locks and at merge time. This guide…