SwarmOps
View as Markdown

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#

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 swarmops
Added 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 - ✔ Connected

That 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 swarmops
Added 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 own SWARMOPS_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 gets E_IDENTITY_REQUIRED and reads still work.
  • swarmops, not so. On some machine the short name may be missing (cargo install --bin swarmops installs 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=0

Four 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#

MethodServed
initializeOpens a session at 2025-11-25 or 2025-06-18; a request that names 2026-07-28 gets -32601.
notifications/initialized, other notificationsNo 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/discover2026-07-28 only: the versions served, capabilities and instructions.
tools/listOne page; a cursor is -32602.
tools/callname and arguments, the operation's request.

Protocol versions#

The server speaks three revisions of MCP:

VersionHow a client opens it
2026-07-28No handshake: each request names the version in params._meta, with the client's capabilities.
2025-11-25initialize; the version given to a client that asks for one the handshake does not serve.
2025-06-18initialize; 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, else SWARMOPS_REPO, opened anew for each call, so a call sees what other agents committed meanwhile;
  • the identity is --as, else SWARMOPS_AGENT (<family>/<instance>), with SWARMOPS_RUN_ID naming 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.

ToolCLI commandRequest typeChanges the store
repo_infoswarmops infoInfoReqno
resolve_revswarmops resolve REVResolveRevReqno
read_fileswarmops cat REV:PATHReadFileReqno
read_treeswarmops ls REV[:PATH]ReadTreeReqno
showswarmops show REVShowReqno
logswarmops log [REV]LogReqno
path_historyswarmops history REV:PATHPathHistoryReqno
diffswarmops diff FROM TODiffReqno
op_logswarmops op logOpLogReqno
ref_listswarmops ref listRefListReqno
ref_setswarmops ref set NAME REVRefSetReqmoves a ref
ref_deleteswarmops ref delete NAMERefDeleteReqmoves a ref
undoswarmops op undo OPUndoReqmoves refs
commitswarmops commitCommitReqadds a changeset; with advance, moves a ref
landswarmops land HEADLandReqadds a changeset and moves the target
blob_putswarmops blob putBlobPutReqadds a blob
blob_hasswarmops blob hasBlobHasReqno
verifyswarmops verifyVerifyReqno

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 and ref_delete's expected_old, a commit's parent when it has advance, and land's resolutions.against must be the full 64-hex changeset id you read. A ref name or a relative revision such as main~1 is 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 --expect still takes any revision.
  • Absolute, regular files for {"file": <path>}. In commit, blob_put and land'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:

ToolAsk forPage on with
read_filemax_bytes of 32768 or less (less for a binary file, sent as base64)offset = the result's next_offset
read_treelimit of 100 or lesscursor
log, path_historylimit of 100 or lesscursor
difflimit of 100 or less; max_bytes of 32768 or less for unifiedcursor
showmax_changes of 100 or less
op_loglimit of 20 or lessbefore

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.txt
exit=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:

CodeFor
-32700A line that is not JSON, or not UTF-8.
-32600A batch or anything else that is not one object; a bad jsonrpc or id; a line longer than 64 MiB.
-32601A method the session's revision does not have.
-32602Bad params; no version before initialize; an unknown tool name; arguments that are not an object; a tools/list cursor.
-32603A panic outside an operation (a bug).
-32022A 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=0

Failed 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 init that cannot take its FORMAT back, 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.

Edit on GitHub

On this page