Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ The MCP server exposes the CLI's commands as tools. To register it with Claude C
```json
{
"mcpServers": {
"iterward": { "command": "npx", "args": ["-y", "@push-based/iterward@VERSION", "mcp"] }
"iterward": { "command": "npx", "args": ["-y", "@push-based/iterward@latest", "mcp"] }
}
}
```

Replace `VERSION` with the Iterward version your runs use, such as `0.4.0`. The server's `build-loop` tool gives an agent the build-loop skill, which interviews you about a workflow and writes a loop file that compiles.
To keep a run on the version that started it, see [Register the server](packages/iterward/docs/mcp.md#register-the-server). The server's `build-loop` tool gives an agent the build-loop skill, which interviews you about a workflow and writes a loop file that compiles.

## Documentation

Expand Down
17 changes: 11 additions & 6 deletions packages/iterward/docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@ To register the server with Claude Code, add it to the `.mcp.json` file in the r
```json
{
"mcpServers": {
"iterward": { "command": "npx", "args": ["-y", "@push-based/iterward@VERSION", "mcp"] }
"iterward": { "command": "npx", "args": ["-y", "@push-based/iterward@latest", "mcp"] }
}
}
```

Replace `VERSION` with the Iterward version your runs use, such as `0.4.0`. An engine of another minor version refuses a run, so a pinned version keeps every session on the engine that started the run. When the repository installs that version, npx runs the installed copy. In a clone of the Iterward repository, run `node packages/iterward/bin.ts mcp`, which Node.js runs without a build step.
An engine of another minor version refuses a run, so after a minor release, `@latest` refuses a run that an earlier minor version started. The refusal names the command that continues the run, such as `npx @push-based/iterward@0.4`. To keep every session on the engine that started the run, replace `latest` with that version, such as `0.4.2`. When the repository installs that version, npx runs the installed copy. In a clone of the Iterward repository, run `node packages/iterward/bin.ts mcp`, which Node.js runs without a build step.

The first start through npx downloads the package. If the client stops waiting before the download ends, restart the server after the download, or install the package.

A client that approves tool calls automatically, such as through an `autoApprove` list, must list only the machine moves and the loop tools: `init`, `build-loop`, `compile`, `new`, `next`, `result`, `validate`, and `show`. The client then asks the human to allow each `answer`, `gate`, and `transition`, the human moves Iterward records. One `answer` call carries every answer of one human message, so the human allows the answers once. When the client offers to always allow one of these three tools, decline, because an always-allowed tool runs without asking the human.
A client that approves tool calls automatically, such as through an `autoApprove` list, must list only the machine moves and the loop tools: `init`, `build-loop`, `compile`, `new`, `next`, `result`, `validate`, `show`, and `dispatch`. The client then asks the human to allow each `answer`, `gate`, and `transition`, the human moves Iterward records. One `answer` call carries every answer of one human message, so the human allows the answers once. When the client offers to always allow one of these three tools, decline, because an always-allowed tool runs without asking the human.

## Tools and arguments

The server has 11 tools, one for each command except `dispatch`, `mcp`, and `--version`. Each tool takes its command's flags as arguments:
The server has 12 tools, one for each command except `mcp` and `--version`. Each tool takes its command's flags as arguments:

| Tool | Arguments |
| --- | --- |
Expand All @@ -35,21 +35,26 @@ The server has 11 tools, one for each command except `dispatch`, `mcp`, and `--v
| `next` | `run` |
| `result` | `run`, and optionally `id`, the dispatch the call closes, from `next` |
| `show` | `run` |
| `dispatch` | `run` |
| `validate` | `artifact` and `run` |
| `answer` | `run` and `answers`, a list with one `{ id, option, answer }` object for each question the human answered, at least 1 |
| `gate` | `gate`, `run`, `decision`, and `note` |
| `transition` | `run`, `to`, and `note` |

`build-loop`, `compile`, and `show` carry `readOnlyHint: true`. `compile` imports the loop's hook modules and calls no hook. `next` opens dispatches, and `validate` runs guards, which can write files. For each command, see [CLI reference](cli.md).
`build-loop`, `compile`, `show`, and `dispatch` carry `readOnlyHint: true`. `compile` imports the loop's hook modules and calls no hook. `next` opens dispatches, and `validate` runs guards, which can write files. For each command, see [CLI reference](cli.md).

The server has no tool for `dispatch`, because a harness's watcher reads the open dispatch through the CLI.
`dispatch` returns the open dispatch, `{ id, step, agent, attempt, document, openedAt, message }`, with `message` byte for byte as `next` returned it. It takes no lock, writes nothing, and refuses when no dispatch is open for the run.

A tool returns the command's output as text. A refusal returns an error result whose text starts with the tool name and `refused:`. A malformed call, such as a missing or unknown argument, returns one whose text starts with the tool name and `malformed call:`. An error text names each argument of the tool as the tool takes it, such as `note`, where the CLI prints the flag `--note`. Text in backticks, such as a command a guard quotes, stays as the CLI prints it. An argument the server can't pass to the CLI, such as one with a NUL character or one past the system's length limit, returns a malformed call.

The server handles one request at a time. Across processes, such as the server and a watcher that runs the CLI, the run's lock lets one command change the run at a time, and a command refused by the lock comes back as a refusal. For the lock, see [One command at a time](cli.md#one-command-at-a-time).

Set the client's request timeout above the longest hook. A `result` or a `transition` waits for every guard and `onEnter` hook it runs. The server doesn't cancel a command, so a call the client gave up on still finishes and changes the run. Pass `id` to `result`: a call retried after a timeout then refuses, instead of closing the dispatch the first call opened.

## Launch an agent in the background

When an orchestrator launches an agent in the background, such as with a workflow tool that returns before the agent ends, the orchestrator passes the agent only the run path, such as `Run: runs/example/run.json`. The agent calls `dispatch` to read its message, and then follows the message. The orchestrator doesn't copy the message from `next` into the launch, because an orchestrator that launches before `next` returns sends a guessed message. A guessed message drops the `Fixes:` lines, which carry the guard errors that only Iterward holds. The orchestrator calls `next` before the launch, so the dispatch is open when the agent reads it, and calls `result` after the agent writes its document.

## Design a loop

`build-loop` returns the build-loop skill: instructions to interview the user about a workflow, settle one decision at a time, and write a loop file and its hooks module that `compile` accepts. The loop file reference and the hooks template follow the instructions, each after a line that names its file. The skill comes from the server's own package, so it matches the engine that compiles the loop. The server's `initialize` result carries instructions that tell an agent to call `build-loop` before it writes or changes a loop file. Without MCP, `npx iterward build-loop` prints the same text.
2 changes: 1 addition & 1 deletion packages/iterward/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];

/** What an agent reads before its first call: where loop design starts, and which calls record a human's move. */
const INSTRUCTIONS =
"Iterward keeps a run of a loop file on the transitions the loop declares. To design or change a loop file, call `build-loop` first, and follow the instructions it returns. To drive a run, call `next`, send its message to the agent it names, and call `result` after the agent writes its document. `answer`, `gate`, and `transition` record a human's move: call them only with the human's own words.";
"Iterward keeps a run of a loop file on the transitions the loop declares. To design or change a loop file, call `build-loop` first, and follow the instructions it returns. To drive a run, call `next`, send its message to the agent it names, and call `result` after the agent writes its document. An agent that got only a run path calls `dispatch` to read its message. `answer`, `gate`, and `transition` record a human's move: call them only with the human's own words.";

const rpcError = (code: number, message: string) => Object.assign(new Error(message), { code });

Expand Down
9 changes: 4 additions & 5 deletions packages/iterward/mcp/spec/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,17 @@ function server() {
return { call };
}

test('the handshake names the server and points at build-loop, and tools/list lists one tool per CLI command but dispatch', async () => {
test('the handshake names the server and points at build-loop, and tools/list lists one tool per CLI command', async () => {
const s = server();
const init = await s.call('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'spec', version: '0' } });
assert.deepEqual([init.result.protocolVersion, init.result.serverInfo.name], ['2025-06-18', 'iterward']);
assert.match(init.result.instructions, /call `build-loop` first/);
assert.equal((await s.call('initialize', { protocolVersion: '1999-01-01' })).result.protocolVersion, '2025-11-25');
const commands = spawnSync(process.execPath, [BIN], { encoding: 'utf8' }).stderr.match(/<(.+)>/)![1].split('|').filter((c) => c !== 'mcp');
const { tools } = (await s.call('tools/list')).result;
assert.deepEqual(tools.map((t: any) => t.name), ['init', 'build-loop', 'compile', 'new', 'next', 'result', 'show', 'validate', 'answer', 'gate', 'transition']);
// `dispatch` serves a harness's watcher through the CLI; no agent needs it.
assert.deepEqual(tools.map((t: any) => t.name), commands.filter((c) => c !== 'dispatch'));
assert.deepEqual(tools.filter((t: any) => t.annotations.readOnlyHint).map((t: any) => t.name), ['build-loop', 'compile', 'show']);
assert.deepEqual(tools.map((t: any) => t.name), ['init', 'build-loop', 'compile', 'new', 'next', 'result', 'show', 'dispatch', 'validate', 'answer', 'gate', 'transition']);
assert.deepEqual(tools.map((t: any) => t.name), commands);
assert.deepEqual(tools.filter((t: any) => t.annotations.readOnlyHint).map((t: any) => t.name), ['build-loop', 'compile', 'show', 'dispatch']);
assert.equal((await s.call('bogus')).error.code, -32601);
assert.equal((await s.call('constructor', { a: 1 })).error.code, -32601, 'a member every object has is no method');
});
Expand Down
5 changes: 3 additions & 2 deletions packages/iterward/mcp/tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Tool } from './shared/handler.ts';
import { answerTool } from './tools/decisions/answer.tool.ts';
import { dispatchTool } from './tools/dispatch/dispatch.tool.ts';
import { nextTool } from './tools/dispatch/next.tool.ts';
import { resultTool } from './tools/dispatch/result.tool.ts';
import { gateTool } from './tools/gates/gate.tool.ts';
Expand All @@ -11,8 +12,8 @@ import { transitionTool } from './tools/run/transition.tool.ts';
import { validateTool } from './tools/run/validate.tool.ts';
import { showTool } from './tools/views/show.tool.ts';

/** One tool per CLI command but `dispatch`, `mcp`, and `--version`, in the order of the CLI's usage line. */
export const TOOLS: Tool[] = [initTool, buildLoopTool, compileTool, newTool, nextTool, resultTool, showTool, validateTool, answerTool, gateTool, transitionTool];
/** One tool per CLI command but `mcp` and `--version`, in the order of the CLI's usage line. */
export const TOOLS: Tool[] = [initTool, buildLoopTool, compileTool, newTool, nextTool, resultTool, showTool, dispatchTool, validateTool, answerTool, gateTool, transitionTool];

/**
* A refusal as a tool caller reads it: the engine words a refusal for the CLI, so each flag of one of the tool's
Expand Down
12 changes: 12 additions & 0 deletions packages/iterward/mcp/tools/dispatch/dispatch.tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createHandler, flags, type Tool, type ToolSchema } from '../../shared/handler.ts';
import { ANNOTATIONS, COMMON } from '../../shared/schema.ts';

const schema: ToolSchema = {
name: 'dispatch',
description:
"Returns the run's open dispatch: its `id`, `step`, `agent`, `attempt`, `document`, `openedAt`, and `message`, byte for byte as `next` returned it. An agent that got only the run path calls it to read its message, then follows the message. It refuses when no dispatch is open for the run, and changes nothing.",
inputSchema: { type: 'object', properties: { run: COMMON.run }, required: ['run'], additionalProperties: false },
annotations: { title: 'Read the open dispatch', ...ANNOTATIONS.read },
};

export const dispatchTool: Tool = { schema, handler: createHandler(schema, (p) => ['dispatch', ...flags(p)]) };
25 changes: 25 additions & 0 deletions packages/iterward/mcp/tools/dispatch/spec/dispatch.tool.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';

import { json, startedRun, text } from '../../../spec/fixtures.ts';
import { dispatchTool } from '../dispatch.tool.ts';
import { nextTool } from '../next.tool.ts';

test('dispatch returns the open dispatch with the message next returned byte for byte, and changes nothing', async () => {
const run = startedRun();
const opened = json(await nextTool.handler({ run }));
const bytes = readFileSync(run);
const open = json(await dispatchTool.handler({ run }));
assert.deepEqual([open.id, open.step, open.agent, open.attempt, open.message], [opened.id, 'discovery', opened.agent, 1, opened.message]);
assert.deepEqual(readFileSync(run), bytes);
assert.equal(dispatchTool.schema.annotations.readOnlyHint, true);
});

test('dispatch refuses when no dispatch is open, and refuses a malformed call', async () => {
const run = startedRun();
const none = await dispatchTool.handler({ run });
assert.deepEqual([none.isError, text(none)], [true, 'dispatch refused: other:\n - no open dispatch']);
assert.equal(text(await dispatchTool.handler({})), 'dispatch malformed call: run is required');
assert.equal(text(await dispatchTool.handler({ run, id: 1 })), 'dispatch malformed call: unknown argument id');
});
Loading