Skip to content

feat(coordinator): MCP orchestration engine, REST API hardening, and task model#120

Open
brooksc wants to merge 2 commits into
johannesjo:mainfrom
brooksc:coordinator-2-mcp-backend
Open

feat(coordinator): MCP orchestration engine, REST API hardening, and task model#120
brooksc wants to merge 2 commits into
johannesjo:mainfrom
brooksc:coordinator-2-mcp-backend

Conversation

@brooksc
Copy link
Copy Markdown
Contributor

@brooksc brooksc commented May 16, 2026

Overview

This is PR 2 of 4 in the coordinator series splitting #100 as requested in the round-4 review. It is stacked on PR 1 (coordinator-1-security) and should be merged after that one. The diff shown here includes PR 1's content; the meaningful delta for this PR is the coordinator engine and REST hardening described below.

PR sequence:

PR Branch Status Contents
1 coordinator-1-security Open Atomic writes, input validators, static analysis configs
2 (this PR) coordinator-2-mcp-backend Open MCP coordinator engine + REST API hardening
3 coordinator-3-store-ipc Pending Frontend store wiring + IPC handlers
4 coordinator-4-ui Pending UI components + coordinator entry points

PRs 3–4 are stacked on this one. Nothing coordinator-related is user-visible until PR 4 adds the NewTaskDialog checkbox and Settings toggle (coordinatorModeEnabled defaults to false).


What's in this PR (delta over PR 1)

MCP orchestration engine (electron/mcp/)

coordinator.ts — core orchestrator singleton

  • Creates and manages coordinated sub-tasks, each in its own git worktree
  • Three-class token RBAC: coordinator / subtask / mobile
  • Per-task done tokens (24-byte random, timing-safe comparison)
  • PTY output subscription for idle/prompt detection
  • Batched review notifications with configurable delay and restaging
  • signal_done / waitForSignalDone with replay cache (requestId dedup for safe retries)
  • Atomic preamble injection and strip via atomic.ts
  • setTaskControl / blockedByHumanControl state machine (coordinator ↔ human hand-off)
  • cleanupTask with double-resolve guard on anySignalResolvers

server.ts — MCP stdio entry point

  • Speaks MCP over stdio to Claude Code; delegates to Electron app via HTTP
  • CLI arg validation: rejects \r/\n in --coordinator-id / --task-id (header injection prevention)
  • Exposes tools: create_task, list_tasks, get_task_status, get_task_diff, get_task_output, send_prompt, wait_for_idle, wait_for_signal_done, merge_task, close_task, review_and_merge_task, signal_done

config.ts — MCP JSON config generation

  • selectMcpJsonDir: selects the right directory for the per-coordinator config
  • writeMcpConfig: writes config (mode 0o600) atomically

preamble.ts — preamble injection and strip

  • Atomically appends a <sub-task-mode> block to CLAUDE.md, AGENTS.md, GEMINI.md, .agent.md, or settings.local.json depending on agent type
  • stripPreambleFromBranch: atomically strips the block on merge/close
  • buildNormalizedPreambleFileDiff: git diff --no-index with anchored-regex path rewriting (prevents false substitutions when tmpdir path appears in file content)

sub-task-preamble.ts — sub-task-side preamble injection
prompt-detect.ts — sliding-window idle/prompt detector
replay-cache.ts — deduplicates wait_for_signal_done retries by requestId
client.tsMCPClient used by the MCP stdio process to call the REST API
mcp-tool-list.ts — builds the tool list advertised to coordinator vs. sub-task roles
types.ts — shared types: CoordinatedTask, ApiTaskSummary, ApiTaskDetail, token classes, etc.


REST API hardening (electron/remote/server.ts)

New coordinator task routes (all require auth):

Method Path Description
POST /api/tasks Create sub-task
GET /api/tasks List sub-tasks (coordinator-scoped)
GET /api/tasks/:id Get status
POST /api/tasks/:id/prompt Send prompt
POST /api/tasks/:id/wait Wait for idle
GET /api/tasks/:id/diff Get diff
GET /api/tasks/:id/output Get scrollback
POST /api/tasks/:id/merge Merge branch
POST /api/tasks/:id/review-merge Diff + merge
DELETE /api/tasks/:id Close/cleanup
POST /api/tasks/:id/done Signal done (subtask token + X-Done-Token)
POST /api/wait-signal Wait for any signal_done

Auth scoping hardening:

  • callerCoordinatorId extracted from verified X-Coordinator-Id header and enforced before all coordinator routes (including wait-signal)
  • create_task: body coordinatorTaskId ignored; header is authoritative; mismatch → 403
  • wait-signal: body coordinatorTaskId ignored entirely; header-only
  • Mobile token restricted to /api/agents only — task routes removed (mobile token is embedded in a QR-code URL reachable by anyone on the local network)
  • Coordinator token without X-Coordinator-Id → 403 on all task routes
  • task.name: 200-char max, control characters stripped (prompt injection prevention)

IPC handlers (electron/ipc/register.ts)

New handlers wired up: StartMCPServer, StopMCPServer, GetMCPStatus, GetMCPLogs, HydrateCoordinatedTask, MCP_CoordinatedTaskClosed, MCP_TaskHydrated, MCP_CoordinatorNotificationAck, MCP_CoordinatorNotificationDropAck, MCP_CoordinatorRestageAfterUserSend


Store / type changes (src/store/)

New Task fields: coordinatorMode, coordinatedBy, controlledBy, mcpConfigPath, preambleFileExistedBefore, signalDone*, needsReview, mcpStartupStatus/Error

New PersistedState / AppStore fields: coordinatorModeEnabled, coordinatorNotificationDelayMs, coordinatorControlHintDismissed, MCPStatus

core.ts, remote.ts, persistence.ts, store.ts, ui.ts — wired up coordinator store state with defaults and persistence round-trip


OpenSpec

openspec/changes/coordinator-mcp-backend/proposal.md — documents the MCP orchestration server, REST task API, three-token auth model, signal/wait lifecycle, preamble injection, and new IPC channels per CLAUDE.md requirement


Tests

File Cases Coverage
electron/mcp/coordinator.test.ts 180+ Lifecycle, preamble injection, waitForIdle, signal/wait, notifications, hydrateTask, setTaskControl, cleanupTask
electron/mcp/coordinator-sequence.test.ts 10+ End-to-end create → signal → merge
electron/mcp/config.test.ts 15 selectMcpJsonDir, writeMcpConfig
electron/mcp/mcp-tool-list.test.ts 10 Tool selection by role
electron/mcp/prompt-detect.test.ts 15 Idle detection patterns
electron/remote/coordinator-scoping.test.ts 40 HTTP integration: coordinator scoping, subtask token restrictions, mobile token restrictions, create_task body-vs-header scoping
electron/ipc/register-mcp.test.ts 10 StartMCPServer input validation
electron/ipc/register.test.ts 5 IPC handler registration
electron/ipc/docker-config.test.ts 10 Docker MCP config paths
electron/mcp/docker.integration.test.ts Docker lifecycle (skipped without Docker daemon)

Total: 295+ test cases. npm test → 859 pass, 12 skipped.

Test plan

  • npm run compile && npm run typecheck && npm run lint && npm run format:check — pass
  • npm test — 859 pass, 12 skipped (Docker integration skipped without daemon)
  • git diff --check johannesjo/main...HEAD — clean

🤖 Generated with Claude Code

brooksc and others added 2 commits May 16, 2026 10:32
…tooling

atomic.ts / atomic.test.ts
- Crash-safe atomic file writes via temp-file + rename
- fsync temp file and directory entry for durability
- Preserve existing file mode on overwrite; use fchmod after open
  to set exact mode bits, bypassing process umask
- Tests force umask=0o022 in exact-mode cases for CI determinism

validation.ts / validation.test.ts
- validateBranchName: mirrors git check-ref-format --branch rules
  (rejects HEAD; accepts FETCH_HEAD/ORIG_HEAD per actual git behaviour)
- validateUUID: enforces v4 UUID (version nibble 4, variant nibble 8/9/a/b)

Static analysis
- .semgrep/filesystem-safety.yml: flag raw writeFileSync in MCP/coordinator
  paths; pattern-either covers both qualified (fs.writeFileSync) and
  unqualified forms
- .semgrep/electron-security.yml, .semgrep/ipc-auth.yml: IPC auth checks
- .dependency-cruiser.cjs: architecture boundary enforcement
- .gitleaks.toml: secret-scanning rules
- knip.config.ts: dead-export detection; server.ts listed as entry point
  (lands in coordinator-2-mcp-backend)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…task model

This is PR 2 of 4 in the coordinator series (stacked on coordinator-1-security).

## MCP orchestration engine (electron/mcp/)

coordinator.ts — core orchestrator singleton
- Creates and manages coordinated sub-tasks, each in its own worktree
- Three-class token RBAC: coordinator / subtask / mobile
- Per-task done tokens (24-byte random, timing-safe comparison)
- PTY output subscription for idle detection; sliding-window prompt detector
- Batched review notifications with configurable delay and restaging
- signal_done / waitForSignalDone with replay cache (requestId dedup)
- Atomic preamble injection and strip via atomic.ts
- setTaskControl / blockedByHumanControl state machine
- cleanupTask with double-resolve guard on anySignalResolvers

server.ts — MCP stdio entry point
- Speaks MCP over stdio to Claude Code; delegates to Electron app via HTTP
- CLI arg validation (rejects \r/\n in --coordinator-id / --task-id)
- Exposes: create_task, list_tasks, get_task_status, get_task_diff,
  get_task_output, send_prompt, wait_for_idle, wait_for_signal_done,
  merge_task, close_task, review_and_merge_task, signal_done

config.ts — MCP JSON config generation
- selectMcpJsonDir: finds the right directory for the per-coordinator config
- writeMcpConfig: writes config (mode 0o600) via atomicWriteFileSync

preamble.ts — preamble injection / strip
- injectPreamble: atomically appends <sub-task-mode> block to CLAUDE.md,
  AGENTS.md, GEMINI.md, .agent.md, or settings.local.json
- stripPreambleFromBranch: atomically strips the block on merge/close
- buildNormalizedPreambleFileDiff: git diff --no-index with anchored regex
  path rewriting (prevents false substitutions from content matching tmpdir)

sub-task-preamble.ts — sub-task-side preamble injection
prompt-detect.ts — detects when Claude is waiting for user input
replay-cache.ts — deduplicates wait_for_signal_done retries
client.ts — MCPClient used by the MCP stdio server to call the REST API
mcp-tool-list.ts — builds the tool list advertised to coordinator/subtask
types.ts — shared types (CoordinatedTask, ApiTaskSummary, ApiTaskDetail, etc.)

## REST API (electron/remote/server.ts)

New coordinator task routes, all requiring auth:
- POST   /api/tasks               create sub-task
- GET    /api/tasks               list sub-tasks (coordinator-scoped)
- GET    /api/tasks/:id           get status
- POST   /api/tasks/:id/prompt    send prompt
- POST   /api/tasks/:id/wait      wait for idle
- GET    /api/tasks/:id/diff      get diff
- GET    /api/tasks/:id/output    get scrollback
- POST   /api/tasks/:id/merge     merge branch
- POST   /api/tasks/:id/review-merge  diff + merge
- DELETE /api/tasks/:id           close/cleanup
- POST   /api/tasks/:id/done      signal done (subtask token + X-Done-Token)
- POST   /api/wait-signal         wait for any signal_done

Auth scoping hardening:
- callerCoordinatorId extracted from verified X-Coordinator-Id header and
  enforced before all coordinator routes (including wait-signal)
- create_task: body coordinatorTaskId ignored; header value is authoritative;
  mismatched body value returns 403
- wait-signal: body coordinatorTaskId ignored entirely; header-only
- Mobile token restricted to /api/agents only (task routes removed)
- Coordinator token without X-Coordinator-Id gets 403 on all task routes
- task.name sanitized: 200-char max, control characters stripped
- Design-intent comment on coordinator-token signal_done exemption

## IPC handlers (electron/ipc/register.ts)

New handlers: StartMCPServer, StopMCPServer, GetMCPStatus, GetMCPLogs,
HydrateCoordinatedTask, MCP_CoordinatedTaskClosed, MCP_TaskHydrated,
MCP_CoordinatorNotificationAck, MCP_CoordinatorNotificationDropAck,
MCP_CoordinatorRestageAfterUserSend

## Store / type changes (src/store/)

types.ts — new Task fields: coordinatorMode, coordinatedBy, controlledBy,
mcpConfigPath, preamble*, signalDone*, needsReview, mcpStartupStatus, etc.
New AppStore fields: coordinatorModeEnabled, coordinatorNotificationDelayMs,
coordinatorControlHintDismissed. New MCPStatus interface.
core.ts, remote.ts, persistence.ts, store.ts, ui.ts — wired up coordinator
store state; mcpStatus, coordinatorModeEnabled defaults; persistence round-trip
for all new task fields

## OpenSpec

openspec/changes/coordinator-mcp-backend/proposal.md — documents the MCP
orchestration server, REST task API, three-token auth model, signal/wait
lifecycle, preamble injection, and new IPC channels per CLAUDE.md requirement

## Tests

electron/mcp/coordinator.test.ts       — 180+ cases: lifecycle, preamble
  injection, waitForIdle, signal/wait, notifications, hydrateTask,
  setTaskControl, cleanupTask, validateBranchName extended rules
electron/mcp/coordinator-sequence.test.ts — end-to-end create→signal→merge
electron/mcp/config.test.ts            — selectMcpJsonDir, writeMcpConfig
electron/mcp/mcp-tool-list.test.ts     — tool selection by role
electron/mcp/prompt-detect.test.ts     — idle detection patterns
electron/remote/coordinator-scoping.test.ts — 40 HTTP integration tests:
  coordinator scoping, subtask token restrictions, mobile token restrictions
  (GET /api/tasks, GET /api/tasks/:id, POST /api/wait-signal all blocked),
  create_task scoping (header authoritative, body mismatch → 403)
electron/ipc/register-mcp.test.ts      — StartMCPServer input validation
electron/ipc/register.test.ts          — IPC handler registration
electron/ipc/docker-config.test.ts     — Docker MCP config paths
electron/mcp/docker.integration.test.ts — Docker lifecycle (skipped without Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@johannesjo
Copy link
Copy Markdown
Owner

johannesjo commented May 16, 2026

Thank you very much for your work on this! <3

Follow-up review after splitting the pass across remote auth, coordinator lifecycle/persistence, and MCP execution paths. I think these need attention before merge:

  1. Connect Phone cannot authenticate with the new mobile token. The QR URL now embeds mobileToken (electron/remote/server.ts:867), but WebSocket auth still accepts only coordinator tokens (electron/remote/server.ts:760). The phone SPA sends the QR token as its first WS auth message (src/remote/ws.ts:41), so scanning the QR gets 4001 Unauthorized and reloads as unauthenticated. Either allow mobile-scoped WS access to the existing agent protocol, or move the mobile UI to a REST-only read-only flow.

  2. Connect Phone can return unreachable LAN URLs while MCP is active. If MCP started the shared remote server on 127.0.0.1, StartRemoteServer intentionally skips rebinding while a coordinator is active (electron/ipc/register.ts:1061) but still returns wifiUrl/tailscaleUrl from that same loopback-bound server (electron/remote/server.ts:878). The modal will show QR URLs that other devices cannot reach. Track the bind host and either return an explicit unavailable state, run a separate LAN/mobile server, or only expose LAN URLs after a real 0.0.0.0 bind.

  3. Coordinator state is typed but not persisted/restored. PersistedState/PersistedTask gained coordinator fields, and main-process startup checks coordinatorModeEnabled (electron/ipc/register.ts:1306), but saveState() does not write those top-level fields (src/store/persistence.ts:86) and task restore omits coordinatedBy, controlledBy, mcpConfigPath, signalDone*, needsReview, etc. (src/store/persistence.ts:558, src/store/persistence.ts:641). Restart loses the coordinator setting, child nesting, MCP config paths, signal state, and review state, so hydration cannot work reliably.

  4. Deregistering a coordinator drops live child-task backend state. deregisterCoordinator() unsubscribes each child PTY and deletes the task from this.tasks (electron/mcp/coordinator.ts:1361, electron/mcp/coordinator.ts:1391) without necessarily killing those child agents/worktrees. A later signal_done for that still-running child will be task-not-found, and real PTY output will no longer drive orphan/review notifications. Keep child task records until each child is closed, or explicitly transfer them to human/orphaned review state while preserving the done endpoint.

  5. Sub-task agent args are Claude-specific even when the coordinator agent is not Claude. createTask() appends --mcp-config and --dangerously-skip-permissions to every configured agent command (electron/mcp/coordinator.ts:633, electron/mcp/coordinator.ts:637). The rest of the app models skip-permission args per agent (src/components/TaskAITerminal.tsx:592), so Codex/Gemini/opencode sub-tasks will be misconfigured or fail to start. Either restrict coordinator sub-tasks to supported agents, or make MCP config and skip-permission injection agent-specific.

Secondary follow-ups from the pass: ConnectPhoneModal ignores { stopped: false, reason: 'coordinator_active' } from stopRemoteAccess() (src/components/ConnectPhoneModal.tsx:145), and StartMCPServer should validate skipPermissions/propagateSkipPermissions as booleans plus UUID-check coordinatorTaskId before using it in temp paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants