ai-coord-mcp
# ai-coord-mcp
A shared coordination layer between multiple AI coding assistants — not an orchestrator.
This MCP server never launches, manages, or talks to any AI process. It only exposes a
task queue, a per-task message thread, and progress reporting, all persisted as
human-readable JSON on disk. Claude Code and OpenCode (or any other MCP-capable client)
are launched manually, in separate terminals, and both point their MCP config at this
server. Claude typically plays coordinator (creates/reviews tasks); OpenCode plays
implementer (claims/updates/completes them) — but the server has no opinion about that,
it just stores state.
```
Claude Code (Sonnet) OpenCode (DeepSeek/Qwen/etc.)
│ │
│ create_task, list_tasks, │ claim_task, update_progress,
│ approve_task, request_revision │ complete_task, post_message
▼ ▼
ai-coord-mcp (this server)
│
▼
.ai/coordination/*.json (on disk)
```
## Design
- **Transport: stdio.** Each client spawns its own copy of this server as a subprocess.
"The same server" means the same *storage directory* (`COORDINATION_DIR`), not one
process. Every tool call re-reads the relevant JSON file from disk before mutating it;
writes go through temp-file-then-rename (`writeJsonAtomic`) so a reader never sees a
half-written file; a cross-process mutex (`withLock`, `src/storage/lock.ts`) built on
`mkdir` guards concurrent writers, with stale-lock reclaim after 10s and an ownership
token per holder.
- **IDs**: sequential `task-0001`, `task-0002`, … assigned under a short-lived lock.
Validated against `/^task-\d{4,}$/` before being joined into any filesystem path.
- **Optimistic concurrency**: every task carries a `version` counter. Pass
`expected_version` to any mutating tool to get a `ConflictError` instead of a silent
overwrite.
- **Not implemented**: binary attachments (the `files` field is path references only,
not stored content), archiving (the `archive/` directory exists but nothing writes to
it), typed `branch`/`commit` fields (use the `metadata` convention below instead),
structured Definition-of-Done / `verify_command` (use `expected_output` instead), task
relationships / `parent_id`/`blocks` (use `tags` to group instead), MCP Resources /
`resources/subscribe` (use `wait_for_task` instead).
**Status model:**
```
pending --claim_task--> claimed --update_progress--> in_progress
^ | |
| | both claimed and in_progress can complete_task--> completed
| | |
| └──────────────────────cancel_task─────────────────┐ approve_task│request_revision
| | | |
└───────────────reopen_task (from cancelled/completed/approved)◄────────────┘ ▼ ▼
approved revision_requested
(loops back through
update_progress →
in_progress again)
```
`update_progress` auto-advances `claimed` or `revision_requested` to `in_progress` on its
first call in that cycle — there's no separate "start_task" tool.
## Storage layout
```
.ai/coordination/
tasks/task-0001.json one file per task (includes its own audit history[])
messages/task-0001.json array of {id, taskId, author, timestamp, content}
progress/task-0001.json latest snapshot + full history of updates
archive/ reserved, unused
.locks/ transient lock directories, safe to delete if empty
```
Everything is plain JSON — `cat .ai/coordination/tasks/task-0001.json` works fine while
the server is running.
## Setup
```bash
cd ai-coord-mcp
pnpm install
pnpm run build
```
Both Claude Code and OpenCode need to point at the same `COORDINATION_DIR` (an absolute
path) so they share state regardless of which directory each was launched from.
**Claude Code** — add to `.mcp.json` (project) or via `claude mcp add`:
```json
{
"mcpServers": {
"ai-coord": {
"command": "node",
"args": ["/absolute/path/to/ai-coord-mcp/dist/index.js"],
"env": {
"COORDINATION_DIR": "/absolute/path/to/shared/.ai/coordination"
}
}
}
}
```
**OpenCode** — add the equivalent entry to its MCP config (`opencode.json` /
`~/.config/opencode/config.json`, depending on your OpenCode version):
```json
{
"mcp": {
"ai-coord": {
"type": "local",
"command": ["node", "/absolute/path/to/ai-coord-mcp/dist/index.js"],
"environment": {
"COORDINATION_DIR": "/absolute/path/to/shared/.ai/coordination"
}
}
}
}
```
If `COORDINATION_DIR` is unset, the server defaults to `<cwd>/.ai/coordination` — only
safe if you know both clients will be launched from the same working directory.
## Getting started
1. Build the server and wire up `COORDINATION_DIR` for both clients (Setup above).
2. Launch Claude Code and OpenCode in separate terminals, both in the shared repo.
3. In Claude Code, create a task:
> Create a task: title "Sort inventory list", description "Implement stable sort by
> SKU for the inventory panel, keep the existing filter hooks working",
> expected_output "inventory.ts updated, existing tests still pass, new test for
> empty list".
→ calls `create_task`, returns `task-0001` (status `pending`).
4. In OpenCode:
> Check ai-coord for any pending tasks.
→ calls `claim_next_task`, works the task, calls `update_progress` a few times, then
`complete_task`.
5. Back in Claude Code:
> Review task-0001 and approve or request revision.
→ reviews the diff, calls `approve_task` or `request_revision` (the latter posts
feedback to the thread; OpenCode reads it via `get_messages` and repeats from
`update_progress`).
## Tools
| Tool | Purpose |
|---|---|
| `create_task` | Create a task (status `pending`) |
| `list_tasks` | List task summaries (no `history[]`, includes computed `idleSeconds`), filter by `status` / `assignee` / `creator` / `tag` |
| `get_task` | Fetch one task by id, full record including `history[]` |
| `search_tasks` | Substring search over title/description/tags, same summary shape as `list_tasks` |
| `claim_task` | `pending` → `claimed` |
| `claim_next_task` | Atomically claim the first eligible pending task (optionally by `tag`) instead of a `list_tasks` + `claim_task` round trip; returns `null` if none available |
| `wait_for_task` | Block (polling, lock-free) until a task's status changes or `timeout_seconds` elapses, instead of looping `get_task` yourself |
| `complete_task` | `claimed`/`in_progress`/`revision_requested` → `completed` |
| `approve_task` | `completed` → `approved` |
| `request_revision` | `completed` → `revision_requested` (also posts the feedback as a message) |
| `cancel_task` | any open status → `cancelled` |
| `reopen_task` | `cancelled`/`completed`/`approved` → `pending`, clears assignee |
| `post_message` | Append to a task's conversation thread |
| `get_messages` | Read a task's thread, optionally only messages `since` an ISO timestamp |
| `update_progress` | Report `percent` (0-100) / `current_step` / `notes`; auto-advances status to `in_progress` |
| `get_progress` | Latest progress snapshot + history |
All mutating tools accept an optional `expected_version` — pass the task's current
`version` to get a `ConflictError` instead of clobbering a concurrent change.
Every status-changing tool above (`claim_task` through `reopen_task`) returns the same
summary shape as `list_tasks` — no `history[]` — so an implementer with a small context
window (a local model, say) isn't handed the whole growing transition/note log back on
every call. Same for `update_progress`: it returns the current snapshot, not the growing
`history[]`. `get_task`/`get_progress` remain the only tools that return full history.
## Workflow conventions
- **Waiting for work**: use `wait_for_task` over polling `get_task`/`list_tasks` — one
call instead of N, blocks server-side without holding a lock, returns `timed_out: true`
as a normal result.
- **Picking up work**: use `claim_next_task` over `list_tasks({status:"pending"})` +
`claim_task` — same two steps, atomic, retries automatically if another claimer wins
the race.
- **Git state**: no typed `branch`/`commit` field. Convention: `create_task`'s `metadata`
carries `{ baseCommit, branch }`; both agents read/write those same keys.
`expected_output` plus this README is the Definition-of-Done contract.
- **Implementer loop**: `claim_next_task` (or `claim_task` on a specific id) → work → a
few `update_progress` calls → `complete_task`. If revision is requested, `get_messages`
for the feedback, then repeat from `update_progress`.
- **Coordinator loop**: `create_task` → `wait_for_task` (or check back later) → on
`completed`, review the diff at `working_directory` → `approve_task` or
`request_revision`.
## Tests
```bash
pnpm test
```
Runs `node:test` (via `tsx`) over every `src/**/*.test.ts` file: the storage layer
(atomic writes, cross-process lock semantics including the stale-lock/ownership-token
edge case, id generation, message/progress persistence) and the full tool surface
(state-machine transitions, `TASK_ID` schema rejecting malformed/traversal ids, version
conflicts) driven through the real `registerXxx()` functions against a fresh temp
`COORDINATION_DIR` per test file.
TDQS
Scored across 16 tools
Each tool targets a distinct resource/action: task CRUD, claiming, lifecycle transitions (complete/cancel/reopen/approve/revision), messaging, progress, and waiting. Even similar tools like list_tasks/search_tasks and claim_task/claim_next_task are clearly differentiated by purpose and described behavior.
All tools follow a consistent verb_noun pattern in snake_case (create_task, get_task, claim_task, update_progress, wait_for_task). Verb choices are semantically aligned with actions (list/get/search for reads; claim/complete/cancel for state changes), making the set predictable and easy to navigate.
At 16 tools, the set sits at the upper boundary of typical scope, but every tool serves a distinct purpose in the coordination workflow. The slightly elevated count is justified by the inclusion of specialized tools like claim_next_task and wait_for_task that optimize common operations.
The surface covers the full task lifecycle (create, read, claim, complete, cancel, reopen, approve, revision) plus messaging and progress tracking. Minor gaps exist: there's no tool to edit task metadata (e.g., title, description, assignee) after creation, but this can be worked around via cancel/recreate.