From c041a418e079baa60ab6400561216c2a4b4ecf4c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 13 Oct 2026 13:23:56 +0200 Subject: [PATCH] feat(mcp): codeman mcp, a Model Context Protocol server for any MCP client Agents pick tools from MCP clients and registries, and Codeman had no MCP surface: an assistant in Claude Desktop, Cursor, Codex or Gemini CLI could not start or drive Codeman sessions without hand-written HTTP calls. `codeman mcp` is a stdio MCP server and a CLIENT of the existing HTTP API (it reuses codeman agent's transport and guards; no new route). Seven tools: list_sessions, start_session, send_prompt, wait_for_session, read_output, interrupt_session, delete_session. - Dual-era: answers the legacy initialize handshake (2024-11-05 to 2025-11-25) and the 2026-07-28 revision (per-request _meta, server/discover, resultType, and the ttlMs/cacheScope hints a modern tools/list must carry: Claude Code 2.1.296 drops every tool without them). - Never guesses a server: the URL comes from --url or CODEMAN_API_URL. Unconfigured, it still lists its tools and every call says how to configure it. - Prompting, interrupting and deleting are limited to sessions the process started (or, inside a Codeman pane, that session's own workers) unless --allow-any-session; never its own session. Control bytes are refused before any request. - Waits default to 45 s (Codex's MCP tool timeout is 60 s), max 600 s. - read_output returns text only: a client that prefers structuredContent otherwise showed the model metadata instead of the output. - config/mcp-registry/server.json and package.json mcpName prepare the official MCP registry listing; sync-plugin.mjs keeps its version. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 + README.md | 49 + config/mcp-registry/server.json | 43 + docs/api-reference.md | 2 + docs/extending-codeman.md | 4 + docs/wiki/Driving-Codeman-From-An-Agent.md | 45 + package.json | 1 + scripts/sync-plugin.mjs | 18 + src/cli-agent.ts | 18 +- src/cli.ts | 20 + src/mcp-server.ts | 1070 ++++++++++++++++++++ test/mcp-server.test.ts | 533 ++++++++++ 12 files changed, 1797 insertions(+), 8 deletions(-) create mode 100644 config/mcp-registry/server.json create mode 100644 src/mcp-server.ts create mode 100644 test/mcp-server.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 15d32bfc..fc3a1c55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,6 +202,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents in modes that never get the claude-only preamble (`seedAgentSessionPreamble` is local-claude only; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. + **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) **Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`. diff --git a/README.md b/README.md index 4fa42a9e..3c870fdf 100644 --- a/README.md +++ b/README.md @@ -962,6 +962,55 @@ codeman agent rm "$SID" # any session except t Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill. +### MCP server (`codeman mcp`): drive Codeman from any MCP client + +`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged. + +```bash +# Claude Code +claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp +``` + +```json +{ + "mcpServers": { + "codeman": { + "command": "codeman", + "args": ["mcp"], + "env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" } + } + } +} +``` + +```toml +# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits +[mcp_servers.codeman] +command = "codeman" +args = ["mcp"] +env = { CODEMAN_API_URL = "https://127.0.0.1:3000" } +tool_timeout_sec = 120 +``` + +The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `"CODEMAN_PASSWORD"` to `env` when the server has a password (or let it read the data dir's `.env`). Without the `codeman` command on PATH (a git checkout run without the installer), use `node /path/to/Codeman/dist/index.js mcp`. + +| Tool | Does | +| ---- | ---- | +| `list_sessions` | Every session you can see, with its run mode, status, folder, and whether these tools may prompt it | +| `start_session` | A new worker in a case (a fresh scratch folder, or an existing case) in any installed CLI; waits until it can take a prompt | +| `send_prompt` | Types a one-line prompt and presses Enter; optionally waits for the end of the turn | +| `wait_for_session` | Waits for a signal (`stop`, `blocked`, `idle`, `working`, `exit`) or a literal marker in the output | +| `read_output` | The last answer, the whole conversation, or the last bytes of the terminal | +| `interrupt_session` | One Escape: stops the current turn, keeps the conversation | +| `delete_session` | Closes a worker (its case folder stays) | + +Rules the server enforces rather than documents: + +- **It never guesses a server.** The URL comes from `CODEMAN_API_URL` or `--url`; without one it still starts and lists its tools, and every call says how to configure it. +- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions this server started (and, run inside a Codeman session, that session's own workers). Listing, reading and waiting work on everything. `codeman mcp --allow-any-session` lifts the limit, for a user who wants an assistant to drive their existing tabs. It never acts on the session it runs in. +- **Waits fit the client.** A wait defaults to 45 s (Codex cuts MCP tools off at 60 s by default) and is capped at 600 s; `timed_out` / `still_working` means call again. +- **Both protocol eras.** It answers the 2026-07-28 revision (per-request `_meta`, `server/discover`) and the `initialize` handshake of 2024-11-05 to 2025-11-25. + ### Hooks (events flowing _back_ to Codeman) Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing. diff --git a/config/mcp-registry/server.json b/config/mcp-registry/server.json new file mode 100644 index 00000000..665bbc20 --- /dev/null +++ b/config/mcp-registry/server.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.Ark0N/codeman", + "title": "Codeman", + "description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman", + "version": "1.41.0", + "websiteUrl": "https://getcodeman.com", + "repository": { + "url": "https://github.com/Ark0N/Codeman", + "source": "github" + }, + "packages": [ + { + "registryType": "npm", + "identifier": "aicodeman", + "version": "1.41.0", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + } + ], + "environmentVariables": [ + { + "name": "CODEMAN_API_URL", + "description": "Where your Codeman server runs, for example https://127.0.0.1:3000. Never guessed.", + "isRequired": true, + "format": "string" + }, + { + "name": "CODEMAN_PASSWORD", + "description": "Codeman's HTTP Basic password, when the server has one.", + "isRequired": false, + "isSecret": true, + "format": "string" + } + ] + } + ] +} diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a53134e..78c09b80 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -557,6 +557,8 @@ geometry was read. The capture runs synchronous tmux calls on the server; the `codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. +`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. + ## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`) A `multipart/form-data` body with one `image` part. The file is written into the diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index 9d19ff0d..b172c2c0 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -167,6 +167,10 @@ agent skill instead of teaching it these calls by hand: `skills/codeman` in the per-case injection on Claude session create). The skill carries the guard, the safety rules, and verified wait/orchestration recipes. +If the caller speaks the Model Context Protocol (Claude Desktop, Cursor, Codex, any MCP +client), point it at `codeman mcp` instead: seven tools over these same endpoints, +configured with `CODEMAN_API_URL`. See the README section "MCP server". + The common ones: ```bash diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 1a5b16a2..b5cd8802 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -90,6 +90,51 @@ codeman agent rm "$SID" # any session except t - **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the worker exited, `4` refused. `--json` prints the response's `data`. +## The MCP server + +`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server, so any MCP +client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor) can drive Codeman, from +inside a Codeman session or from an assistant on your desktop. It is a client of the same +HTTP API, with seven tools: `list_sessions`, `start_session`, `send_prompt`, +`wait_for_session`, `read_output`, `interrupt_session` and `delete_session`. + +```bash +# Claude Code +claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp +``` + +```json +{ + "mcpServers": { + "codeman": { + "command": "codeman", + "args": ["mcp"], + "env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" } + } + } +} +``` + +```toml +# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits +[mcp_servers.codeman] +command = "codeman" +args = ["mcp"] +env = { CODEMAN_API_URL = "https://127.0.0.1:3000" } +tool_timeout_sec = 120 +``` + +The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `CODEMAN_PASSWORD` to +`env` when the server has a password. + +- **It never guesses a server.** Without `CODEMAN_API_URL` (or `--url`) every call says how + to configure it. +- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions it + started; listing, reading and waiting work on everything. `codeman mcp + --allow-any-session` lifts that, for an assistant that should drive your existing tabs. +- **Waits are short by default** (45 s, at most 600 s), so they fit a client's tool + timeout. A `timed_out` answer means call again. + ## The manual path The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill diff --git a/package.json b/package.json index ee58ec5c..b471378e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "aicodeman", "version": "1.41.0", "description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.", + "mcpName": "io.github.Ark0N/codeman", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/scripts/sync-plugin.mjs b/scripts/sync-plugin.mjs index 80963044..25c023e2 100644 --- a/scripts/sync-plugin.mjs +++ b/scripts/sync-plugin.mjs @@ -29,6 +29,9 @@ const SOURCE = 'skills/codeman'; const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`; const MIRROR = `${PLUGIN_DIR}/skills/codeman`; const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json']; +// The MCP registry manifest for `codeman mcp` (published with `mcp-publisher` from its +// directory): the server and its npm package both carry the release version. +const MCP_REGISTRY_MANIFEST = 'config/mcp-registry/server.json'; const check = process.argv.includes('--check'); const { version } = JSON.parse(readFileSync('package.json', 'utf8')); @@ -78,6 +81,21 @@ for (const file of MANIFESTS) { if (changed && !check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n'); } +// 3. The MCP registry manifest. +{ + const json = JSON.parse(readFileSync(MCP_REGISTRY_MANIFEST, 'utf8')); + const targets = [json, ...(json.packages ?? []).filter((p) => p.registryType === 'npm')]; + let changed = false; + for (const target of targets) { + if (target.version !== version) { + drift.push(`${MCP_REGISTRY_MANIFEST}: ${target.version} -> ${version}`); + target.version = version; + changed = true; + } + } + if (changed && !check) writeFileSync(MCP_REGISTRY_MANIFEST, JSON.stringify(json, null, 2) + '\n'); +} + if (drift.length === 0) { console.log(`plugin in step: mirror identical, manifests at ${version}`); } else if (check) { diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 4a295af5..58069048 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -253,20 +253,21 @@ export interface RequestOptions { headers?: Record; /** Socket timeout; long-polls pass their own timeout plus headroom. */ timeoutMs?: number; + /** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */ + signal?: AbortSignal; } export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise; /** Every request carries these; they are ignored on endpoints that do not read them. */ export function baseHeaders(ctx: AgentContext): Record { - const headers: Record = { - Accept: 'application/json', - // Tags sessions this caller spawns as its children (lineage in the web UI). - // Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case - // directory as deletable agent scratch, so it rides only the spawn request that - // may create one (see agentSpawn), never anything else. - 'X-Codeman-Parent-Session': ctx.selfId, - }; + const headers: Record = { Accept: 'application/json' }; + // Tags sessions this caller spawns as its children (lineage in the web UI). + // Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case + // directory as deletable agent scratch, so it rides only the spawn request that + // may create one (see agentSpawn), never anything else. Absent for a caller outside + // any session (`codeman mcp` launched by a desktop MCP client), which has no self. + if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId; const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined; if (authorization) headers.Authorization = authorization; return headers; @@ -296,6 +297,7 @@ export const httpRequest: ApiRequest = (ctx, options) => { rejectUnauthorized: false, headers, timeout: options.timeoutMs ?? 30_000, + signal: options.signal, }, (res) => { const chunks: Buffer[] = []; diff --git a/src/cli.ts b/src/cli.ts index 03692780..25f678b2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { casePath } from './config/cases-dir.js'; import { assertValidBasePath } from './config/base-path.js'; import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js'; import { registerAgentCommands } from './cli-agent.js'; +import { resolveMcpConfig, runMcpStdio } from './mcp-server.js'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; import { getRalphLoop } from './ralph-loop.js'; @@ -234,6 +235,25 @@ skillCmd registerAgentCommands(program); +// ============ MCP server (any MCP client drives Codeman) ============ + +program + .command('mcp') + .description( + 'Run a Model Context Protocol server on stdio, so MCP clients (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start, prompt, wait on, read and delete Codeman sessions' + ) + .option('--url ', 'Codeman server URL (default: $CODEMAN_API_URL; never guessed)') + .option( + '--allow-any-session', + 'Let the tools prompt, interrupt and delete sessions this server did not start (default: only its own)' + ) + .action(async (options: { url?: string; allowAnySession?: boolean }) => { + await runMcpStdio( + resolveMcpConfig({ url: options.url, allowAnySession: options.allowAnySession, version: pkg.version }) + ); + process.exit(0); + }); + // ============ Session Commands ============ const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions'); diff --git a/src/mcp-server.ts b/src/mcp-server.ts new file mode 100644 index 00000000..c02916ef --- /dev/null +++ b/src/mcp-server.ts @@ -0,0 +1,1070 @@ +/** + * @fileoverview `codeman mcp`: a Model Context Protocol server on stdio, so any MCP + * client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman + * sessions in any installed agent CLI, prompt them, wait for them, read their answers + * and clean up. + * + * A CLIENT of the HTTP API, like `codeman agent` (src/cli-agent.ts), whose transport and + * guards it reuses: no new route, no second server transport (the agent-control plan's + * "Socket API" non-goal stands), auth and ownership unchanged. + * + * Protocol: dual-era, per the 2026-07-28 revision. A legacy client opens with + * `initialize` (revisions 2025-11-25 and earlier) and is served that way; a modern client + * sends per-request `_meta` (`io.modelcontextprotocol/protocolVersion` and + * `.../clientCapabilities`) and may probe with `server/discover`. Messages are + * newline-delimited JSON-RPC on stdin/stdout; stdout carries protocol messages ONLY, + * every log line goes to stderr. + * + * Invariants (each asserted in test/mcp-server.test.ts): + * 1. Never guesses a server: the URL comes from `--url` or `CODEMAN_API_URL`. Without + * one the server still starts and lists its tools (registries introspect that way), + * and every tool call answers with how to configure it. + * 2. Prompting, interrupting and deleting are limited to sessions this process started, + * or (inside a Codeman session) sessions that session spawned, unless the user passed + * `--allow-any-session`. Listing, reading and waiting are open. The allowlist lives + * in process memory on purpose: a restart forgets it, which fails CLOSED. + * 3. Never acts on its own session (inside Codeman), never sends a control byte + * (`inputRefusal`), and keeps a wait under the client's tool timeout by default + * (45 s; Codex's MCP default is 60 s), with a 600 s ceiling. + * 4. A failed tool is a result with `isError: true`, which the model can act on; + * JSON-RPC errors are for protocol faults only. + * + * @module mcp-server + */ +import { createInterface } from 'node:readline'; +import { randomBytes } from 'node:crypto'; +import { + buildInterruptBody, + buildSendBody, + composerReadyMark, + describeFailure, + httpRequest, + inputRefusal, + isSelfSession, + MIN_ID_PREFIX_LENGTH, + stripAnsi, + type AgentContext, + type ApiRequest, + type ApiResponse, + type RequestOptions, + type WaitResult, +} from './cli-agent.js'; +import { credentialsFrom, readCodemanEnvFile } from './codeman-credentials.js'; +import { listClis } from './config/cli-registry/registry.js'; +import { getErrorMessage } from './types.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Protocol constants +// ───────────────────────────────────────────────────────────────────────────── + +/** Revisions served with per-request `_meta` (no handshake). */ +export const MODERN_PROTOCOL_VERSIONS = ['2026-07-28'] as const; +/** Revisions served after an `initialize` handshake, newest first. */ +export const LEGACY_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'] as const; + +const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'; +const META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities'; +const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo'; + +/** JSON-RPC error codes this server emits. */ +export const RPC_ERROR = { + parse: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internal: -32603, + unsupportedProtocolVersion: -32022, +} as const; + +/** Default wait: under Codex's 60 s MCP tool timeout, so a default call never gets cut off. */ +export const DEFAULT_WAIT_MS = 45_000; +/** The server's own ceiling for one wait (`MAX_WAIT_MS` in config/agent-wait.ts). */ +export const MAX_WAIT_MS = 600_000; +/** Cap on text handed back to the model, so one read cannot flood its context. */ +export const MAX_OUTPUT_CHARS = 40_000; +/** Agent-scratch label for a case directory a start_session CREATES (agent-case-marker.ts). */ +const MCP_AGENT_ORIGIN = 'codeman-mcp'; +const WAIT_SIGNALS = ['stop', 'idle', 'exit', 'working', 'blocked'] as const; + +/** + * Caching hints the 2026-07-28 revision REQUIRES on `server/discover` and `tools/list` + * results (Claude Code 2.1.296 rejects a modern `tools/list` without them, so the + * server connects and shows no tools: measured). The tool list is fixed for the life of + * this process and the same for every caller, hence a long TTL and public scope. + */ +const CACHE_HINTS = { ttlMs: 3_600_000, cacheScope: 'public' } as const; + +const SERVER_INSTRUCTIONS = + 'Codeman runs AI coding agent CLIs (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek, OMP) ' + + "and shells in persistent tmux sessions on the user's machine, shown as tabs in a web dashboard. Typical flow: " + + 'start_session (one fresh case per worker that edits files), send_prompt, then wait_for_session or read_output, ' + + 'and delete_session for every worker you started. Sessions you did not start can be listed and read but not ' + + 'prompted, interrupted or deleted, unless the user started this server with --allow-any-session.'; + +// ───────────────────────────────────────────────────────────────────────────── +// Configuration +// ───────────────────────────────────────────────────────────────────────────── + +export interface McpConfig { + /** Codeman server URL; undefined = not configured, and every tool says how to fix it. */ + apiUrl?: string; + /** Why the URL given was refused, when one was given. */ + urlError?: string; + /** This process's own session, when it runs inside a Codeman pane. */ + selfId?: string; + auth?: AgentContext['auth']; + /** `--allow-any-session`: prompt, interrupt and delete sessions this server did not start. */ + allowAnySession: boolean; + version: string; +} + +/** + * Resolve the configuration from flags and the environment. Never guesses a URL: only + * `--url` or `CODEMAN_API_URL` set one. A session id counts only inside a Codeman pane + * (`CODEMAN_MUX=1`); a desktop client's environment has no session of its own. + */ +export function resolveMcpConfig( + options: { url?: string; allowAnySession?: boolean; version: string }, + env: NodeJS.ProcessEnv = process.env, + envFile: () => Record = readCodemanEnvFile +): McpConfig { + const raw = (options.url ?? env.CODEMAN_API_URL ?? '').trim(); + let apiUrl: string | undefined; + let urlError: string | undefined; + if (raw) { + try { + const url = new URL(raw); + if (url.protocol === 'http:' || url.protocol === 'https:') apiUrl = raw.replace(/\/+$/, ''); + else urlError = `"${raw}" is not an http(s) URL`; + } catch { + urlError = `"${raw}" is not a valid URL`; + } + } + const selfId = env.CODEMAN_MUX === '1' ? env.CODEMAN_SESSION_ID?.trim() || undefined : undefined; + const credentials = credentialsFrom(env, envFile()); + return { + apiUrl, + urlError, + selfId, + auth: credentials.password ? credentials : undefined, + allowAnySession: Boolean(options.allowAnySession), + version: options.version, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tool definitions +// ───────────────────────────────────────────────────────────────────────────── + +export interface ToolDefinition { + name: string; + title: string; + description: string; + inputSchema: Record; + annotations: { + readOnlyHint: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint: boolean; + }; +} + +const sessionIdProperty = { + type: 'string', + minLength: MIN_ID_PREFIX_LENGTH, + description: 'Session id from list_sessions or start_session (the full id, or its first 8 characters).', +}; + +const timeoutProperty = { + type: 'integer', + minimum: 1000, + maximum: MAX_WAIT_MS, + description: `How long to wait, in ms (default ${DEFAULT_WAIT_MS}, max ${MAX_WAIT_MS}). Keep it under your own tool-call timeout; call again to keep waiting.`, +}; + +/** + * The tools, in a fixed order (the spec asks for deterministic `tools/list` output, which + * also keeps a client's prompt cache warm). `modes` are the run modes start_session + * offers, from the CLI registry. + */ +export function buildToolDefinitions(modes: readonly string[]): ToolDefinition[] { + return [ + { + name: 'list_sessions', + title: 'List Codeman sessions', + description: + 'List the Codeman sessions you can see. Each one is an AI coding agent CLI (Claude Code, Codex, Gemini CLI, ' + + 'OpenCode, Grok, DeepSeek, ...) or a shell, running in a persistent tmux pane on the Codeman host and shown ' + + 'to the user as a tab. Returns id, name, run mode, status, folder, and whether this server may prompt it. ' + + 'The status is a hint for humans, not something to synchronize on.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + { + name: 'start_session', + title: 'Start a worker session', + description: + 'Start a new worker: an AI coding agent CLI or a shell in a persistent tmux pane on the Codeman host, ' + + "visible to the user as a new tab in their Codeman dashboard and running on the user's own CLI logins. Use " + + 'it to run work in parallel, get a second opinion from a different CLI or model, or hand off a long task. ' + + "`case` names a Codeman case (a folder): a new name creates a fresh scratch folder in Codeman's cases " + + 'directory, an existing case name runs the worker in that project. Give every worker that edits files its ' + + 'own fresh case, and put absolute paths in its prompt. Waits until the CLI can take a prompt where the CLI ' + + 'shows a ready mark. Returns the session id for send_prompt, wait_for_session, read_output and delete_session.', + inputSchema: { + type: 'object', + properties: { + case: { + type: 'string', + minLength: 1, + maxLength: 100, + description: 'Case name: a fresh name such as "review-auth-1", or an existing case.', + }, + mode: { + type: 'string', + enum: [...modes], + default: 'claude', + description: 'Which agent CLI runs in the session. Only CLIs installed on the Codeman host start.', + }, + name: { type: 'string', maxLength: 100, description: 'Optional tab name shown in the dashboard.' }, + wait_ready: { + type: 'boolean', + default: true, + description: 'Wait until the CLI can take a prompt (default true).', + }, + timeout_ms: timeoutProperty, + }, + required: ['case'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + { + name: 'send_prompt', + title: 'Send a prompt to a session', + description: + 'Type a one-line prompt into a session this server started and press Enter. With wait (default true) it ' + + 'blocks until the worker finishes its turn or timeout_ms passes, and reports finished, still_working (call ' + + 'wait_for_session to keep waiting) or exited. Only claude and deepseek sessions report a definite end of ' + + 'turn; for the other CLIs "finished" is inferred from a quiet screen, so for a reliable result ask the ' + + 'worker to print a unique marker when done and use wait_for_session with match. Newlines and control ' + + 'characters are refused: put long instructions in a file and send its path.', + inputSchema: { + type: 'object', + properties: { + session_id: sessionIdProperty, + text: { type: 'string', minLength: 1, description: 'The prompt: one line of printable text.' }, + wait: { type: 'boolean', default: true, description: 'Block until the turn ends (default true).' }, + timeout_ms: timeoutProperty, + }, + required: ['session_id', 'text'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + { + name: 'wait_for_session', + title: 'Wait for a session', + description: + 'Wait until a session reaches a state (until: stop = finished its turn, blocked = waiting on a permission ' + + 'prompt, idle, working, exit) or prints a literal marker (match). Returns timed_out when timeout_ms passes: ' + + 'call again to keep waiting. Your own prompt is echoed into the output, so a marker written whole in the ' + + 'prompt matches at once: ask for it in two halves ("print DONE followed by _7f3a") and match the joined ' + + 'form (DONE_7f3a), one word without spaces. stop and blocked exist only for claude and deepseek sessions.', + inputSchema: { + type: 'object', + properties: { + session_id: sessionIdProperty, + until: { + type: 'array', + items: { type: 'string', enum: [...WAIT_SIGNALS] }, + minItems: 1, + uniqueItems: true, + description: 'Signals to wait for, for example ["stop", "exit"].', + }, + match: { type: 'string', minLength: 1, maxLength: 200, description: 'Literal text to wait for (no regex).' }, + from: { + type: 'string', + enum: ['buffer', 'now'], + default: 'buffer', + description: 'With match: also search output already printed (buffer, default) or only new output (now).', + }, + timeout_ms: timeoutProperty, + }, + required: ['session_id'], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + { + name: 'read_output', + title: "Read a session's output", + description: + "Read what a session produced. last_answer (default) is the worker's last reply, conversation the whole " + + 'exchange, terminal the last tail_bytes of its screen with colors stripped (works for every CLI, use it ' + + `when the others come back empty). Output is capped at ${MAX_OUTPUT_CHARS} characters.`, + inputSchema: { + type: 'object', + properties: { + session_id: sessionIdProperty, + source: { + type: 'string', + enum: ['last_answer', 'conversation', 'terminal'], + default: 'last_answer', + description: 'What to read.', + }, + tail_bytes: { + type: 'integer', + minimum: 100, + maximum: 200_000, + default: 4000, + description: 'With source terminal: how many bytes of the screen to read (default 4000).', + }, + }, + required: ['session_id'], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + { + name: 'interrupt_session', + title: 'Interrupt a session', + description: + 'Press Escape in a session this server started, to stop its current turn. The conversation is kept. One ' + + 'Escape does not always land, so read_output before the next prompt.', + inputSchema: { + type: 'object', + properties: { session_id: sessionIdProperty }, + required: ['session_id'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + { + name: 'delete_session', + title: 'Delete a session', + description: + 'Close a session this server started: ends its CLI and removes its tab. The case folder stays on disk. ' + + 'Delete every worker you started once you have its answer.', + inputSchema: { + type: 'object', + properties: { session_id: sessionIdProperty }, + required: ['session_id'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, + ]; +} + +/** Run modes start_session offers: every enabled registry entry, in registry order. */ +export function registryModes(): string[] { + return listClis() + .filter((cli) => cli.enabled !== false) + .map((cli) => cli.id as string); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tool execution +// ───────────────────────────────────────────────────────────────────────────── + +export interface ToolResult { + text: string; + structured?: Record; + isError?: boolean; +} + +/** A refusal or failure the model can act on: becomes an `isError` result, never a JSON-RPC error. */ +export class ToolError extends Error {} + +interface SessionRow { + id: string; + name?: string; + mode?: string; + status?: string; + workingDir?: string; + parentSessionId?: string | null; +} + +export interface ToolRunnerDeps { + config: McpConfig; + request: ApiRequest; + /** Ids of sessions this process started: the default allowlist for prompting and deleting. */ + started: Set; + /** Exactly-once tag for this process's input (`seq` is the clock). */ + clientId: string; + now?: () => number; +} + +const FULL_ID_LENGTH = 36; + +/** + * Output handed to the model as plain text and nothing else: no structuredContent beside + * it, because a client that prefers structuredContent (Claude Code does) would then show + * the model the metadata instead of the output (measured: a terminal read came back + * "empty"). Over the cap, the kept end is marked in the text itself. + */ +function readableText(text: string, keep: 'head' | 'tail'): string { + if (text.length <= MAX_OUTPUT_CHARS) return text; + return keep === 'tail' + ? `[showing the last ${MAX_OUTPUT_CHARS} of ${text.length} characters]\n${text.slice(text.length - MAX_OUTPUT_CHARS)}` + : `${text.slice(0, MAX_OUTPUT_CHARS)}\n[showing the first ${MAX_OUTPUT_CHARS} of ${text.length} characters]`; +} + +/** A pane capture for reading: CRLF to LF, blank runs collapsed, trailing blank lines dropped. */ +function tidyTerminal(text: string): string { + return text + .replace(/\r\n?/g, '\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd(); +} + +/** `structured` as pretty JSON text: the spec asks for a text copy of structured content. */ +function structuredResult(structured: Record, isError = false): ToolResult { + return { text: JSON.stringify(structured, null, 2), structured, isError }; +} + +// Argument readers: the schema in tools/list is advisory to the client, so every value +// is checked again here and a bad one is an isError result the model can correct. +function readString( + args: Record, + key: string, + opts: { required?: boolean; max?: number } = {} +): string | undefined { + const value = args[key]; + if (value === undefined || value === null) { + if (opts.required) throw new ToolError(`${key} is required`); + return undefined; + } + if (typeof value !== 'string') throw new ToolError(`${key} must be a string`); + if (opts.required && value.length === 0) throw new ToolError(`${key} must not be empty`); + if (opts.max !== undefined && value.length > opts.max) throw new ToolError(`${key} is longer than ${opts.max}`); + return value; +} + +function readBool(args: Record, key: string, fallback: boolean): boolean { + const value = args[key]; + if (value === undefined || value === null) return fallback; + if (typeof value !== 'boolean') throw new ToolError(`${key} must be true or false`); + return value; +} + +function readInt(args: Record, key: string, fallback: number, min: number, max: number): number { + const value = args[key]; + if (value === undefined || value === null) return fallback; + if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) { + throw new ToolError(`${key} must be an integer from ${min} to ${max}`); + } + return value; +} + +function readEnum(args: Record, key: string, allowed: readonly T[], fallback: T): T { + const value = args[key]; + if (value === undefined || value === null) return fallback; + if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) { + throw new ToolError(`${key} must be one of: ${allowed.join(', ')}`); + } + return value as T; +} + +export function createToolRunner(deps: ToolRunnerDeps) { + const { config } = deps; + + function context(): AgentContext { + if (!config.apiUrl) { + throw new ToolError( + config.urlError + ? `Codeman URL refused: ${config.urlError}.` + : 'This MCP server does not know where Codeman runs. Set CODEMAN_API_URL in its environment (for example ' + + 'https://127.0.0.1:3000, the address you open Codeman at) or pass --url, plus CODEMAN_PASSWORD when the ' + + 'server has one, then restart the MCP server.' + ); + } + return { apiUrl: config.apiUrl, selfId: config.selfId ?? '', auth: config.auth }; + } + + async function call(options: RequestOptions, signal?: AbortSignal): Promise { + const ctx = context(); + try { + return await deps.request(ctx, { ...options, signal }); + } catch (err) { + if (signal?.aborted) throw err; + throw new ToolError( + `Cannot reach Codeman at ${ctx.apiUrl}: ${getErrorMessage(err)}. Check that it is running. If every call ` + + 'fails at once, a sandbox around this MCP server may be blocking network access to this machine.' + ); + } + } + + function ensureOk(res: ApiResponse): void { + if (!res.json?.success) throw new ToolError(describeFailure(res)); + } + + async function listRows(signal?: AbortSignal): Promise { + const res = await call({ method: 'GET', path: '/api/v1/sessions' }, signal); + ensureOk(res); + return (res.json?.data as SessionRow[] | undefined) ?? []; + } + + /** The session an id names: the full id or a unique prefix of at least 8 characters. */ + async function findSession(id: string, signal?: AbortSignal): Promise { + if (id.length < MIN_ID_PREFIX_LENGTH) { + throw new ToolError(`session_id "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters`); + } + const rows = await listRows(signal); + const exact = rows.find((row) => row.id === id); + if (exact) return exact; + const matches = id.length < FULL_ID_LENGTH ? rows.filter((row) => row.id.startsWith(id)) : []; + if (matches.length === 1) return matches[0]; + if (matches.length === 0) throw new ToolError(`No session ${id} (list_sessions shows the current ones)`); + throw new ToolError(`session_id "${id}" is ambiguous: ${matches.map((row) => row.id.slice(0, 13)).join(', ')}`); + } + + function isSelf(id: string): boolean { + return config.selfId !== undefined && isSelfSession(config.selfId, id); + } + + function mayControl(row: SessionRow): boolean { + if (config.allowAnySession) return true; + if (deps.started.has(row.id)) return true; + return config.selfId !== undefined && row.parentSessionId === config.selfId; + } + + function guardControl(row: SessionRow, verb: string): void { + if (isSelf(row.id)) + throw new ToolError(`Refusing to ${verb} session ${row.id}: it is the session this server runs in.`); + if (!mayControl(row)) { + throw new ToolError( + `Refusing to ${verb} session ${row.id.slice(0, 8)}${row.name ? ` (${row.name})` : ''}: this server did not ` + + 'start it. Tools may only prompt, interrupt or delete sessions started through them. If the user wants ' + + 'you to drive their existing sessions, they can restart this MCP server with --allow-any-session.' + ); + } + } + + async function waitCall( + sessionId: string, + query: Record, + path: 'wait' | 'wait-output', + timeoutMs: number, + signal?: AbortSignal + ): Promise<{ wait?: WaitResult; limitPaused?: boolean }> { + const res = await call( + { + method: 'GET', + path: `/api/v1/sessions/${encodeURIComponent(sessionId)}/${path}`, + query: { ...query, timeout: timeoutMs }, + timeoutMs: timeoutMs + 10_000, + }, + signal + ); + ensureOk(res); + return (res.json?.data as { wait?: WaitResult; limitPaused?: boolean } | undefined) ?? {}; + } + + /** One word for a wait's outcome, the shape every waiting tool reports. */ + function waitOutcome(wait: WaitResult | undefined): string { + if (!wait) return 'unknown'; + if (wait.signal === 'exit') return 'exited'; + if (wait.ended) return 'ended'; + if (wait.timedOut) return 'timed_out'; + if (wait.matched !== undefined) return wait.matched ? 'matched' : 'timed_out'; + return 'signal'; + } + + const tools: Record, signal?: AbortSignal) => Promise> = { + async list_sessions(_args, signal) { + const rows = await listRows(signal); + const sessions = rows.map((row) => ({ + id: row.id, + short_id: row.id.slice(0, 8), + name: row.name ?? '', + mode: row.mode ?? '', + status: row.status ?? '', + folder: row.workingDir ?? '', + you: isSelf(row.id), + controllable: !isSelf(row.id) && mayControl(row), + })); + return structuredResult({ sessions, count: sessions.length }); + }, + + async start_session(args, signal) { + const caseName = readString(args, 'case', { required: true, max: 100 })!; + const modes = registryModes(); + const mode = readEnum(args, 'mode', modes, 'claude'); + const name = readString(args, 'name', { max: 100 }); + const waitReady = readBool(args, 'wait_ready', true); + const timeoutMs = readInt(args, 'timeout_ms', DEFAULT_WAIT_MS, 1000, MAX_WAIT_MS); + + const body: Record = { caseName, mode }; + if (config.selfId) body.parentSessionId = config.selfId; + if (name) body.sessionName = name; + const res = await call( + { + method: 'POST', + path: '/api/v1/quick-start', + body, + headers: { 'X-Codeman-Agent-Origin': MCP_AGENT_ORIGIN }, + timeoutMs: 120_000, + }, + signal + ); + ensureOk(res); + const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined; + if (!data?.sessionId) throw new ToolError('Codeman answered without a session id'); + deps.started.add(data.sessionId); + + let ready: boolean | null = null; + let note = 'Ready for send_prompt.'; + const mark = composerReadyMark(mode); + if (waitReady && mark) { + const { wait } = await waitCall( + data.sessionId, + { match: mark, from: 'buffer' }, + 'wait-output', + timeoutMs, + signal + ); + const outcome = waitOutcome(wait); + if (outcome === 'exited' || outcome === 'ended') { + ready = false; + note = 'The CLI exited during startup: read_output with source terminal shows why.'; + } else { + ready = outcome === 'matched'; + if (!ready) { + note = + `Not ready within ${timeoutMs} ms (a startup dialog, or a slow first start): read_output with source ` + + 'terminal, then send_prompt once it shows its prompt.'; + } + } + } else if (waitReady) { + note = `${mode} has no ready mark: give it a few seconds, then use markers with wait_for_session to synchronize.`; + } + return structuredResult({ + session_id: data.sessionId, + mode, + case: data.caseName ?? caseName, + case_path: data.casePath ?? '', + ready, + note, + }); + }, + + async send_prompt(args, signal) { + const id = readString(args, 'session_id', { required: true })!; + const text = readString(args, 'text', { required: true })!; + const wait = readBool(args, 'wait', true); + const timeoutMs = readInt(args, 'timeout_ms', DEFAULT_WAIT_MS, 1000, MAX_WAIT_MS); + const refusal = inputRefusal(text); + if (refusal) throw new ToolError(refusal); + const row = await findSession(id, signal); + guardControl(row, 'prompt'); + + const body = buildSendBody(text, { + enter: true, + clientId: deps.clientId, + seq: (deps.now ?? Date.now)(), + wait: wait ? true : undefined, + waitTimeout: wait ? timeoutMs : undefined, + }); + const res = await call( + { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(row.id)}/input`, + body, + timeoutMs: timeoutMs + 10_000, + }, + signal + ); + ensureOk(res); + const data = res.json?.data as + | { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult } + | undefined; + if (data?.dropped) { + throw new ToolError("Dropped: the session's host is waking and its input buffer is full. Send again later."); + } + if (data?.delivered === false && !data.duplicate) { + return structuredResult( + { session_id: row.id, outcome: 'not_delivered', note: 'The session has no live CLI (its pane exited).' }, + true + ); + } + if (!wait) { + return structuredResult({ + session_id: row.id, + outcome: data?.buffered ? 'buffered' : 'sent', + note: 'Sent without waiting: use wait_for_session or read_output.', + }); + } + const outcome = waitOutcome(data?.wait); + const definite = data?.wait?.until?.includes('stop') ?? false; + const result: Record = { + session_id: row.id, + outcome: outcome === 'signal' ? 'finished' : outcome === 'timed_out' ? 'still_working' : outcome, + signal: data?.wait?.signal ?? null, + }; + if (outcome === 'signal') { + result.note = definite + ? 'The worker finished its turn: read_output for its answer.' + : 'The screen went quiet, which for this CLI usually means done but is not definite: read_output to check, ' + + 'or ask for a marker and use wait_for_session with match.'; + } else if (outcome === 'timed_out') { + result.note = 'Still working: call wait_for_session to keep waiting.'; + } + return structuredResult(result); + }, + + async wait_for_session(args, signal) { + const id = readString(args, 'session_id', { required: true })!; + const match = readString(args, 'match', { max: 200 }); + const rawUntil = args.until; + const timeoutMs = readInt(args, 'timeout_ms', DEFAULT_WAIT_MS, 1000, MAX_WAIT_MS); + const from = readEnum(args, 'from', ['buffer', 'now'] as const, 'buffer'); + let until: string[] | undefined; + if (rawUntil !== undefined && rawUntil !== null) { + if (!Array.isArray(rawUntil) || rawUntil.length === 0) throw new ToolError('until must be a non-empty list'); + for (const signalName of rawUntil) { + if (typeof signalName !== 'string' || !(WAIT_SIGNALS as readonly string[]).includes(signalName)) { + throw new ToolError(`until entries must be among: ${WAIT_SIGNALS.join(', ')}`); + } + } + until = rawUntil as string[]; + } + if (until && match) throw new ToolError('Use either until or match, not both.'); + if (!until && !match) throw new ToolError('Give until (signals) or match (a literal marker).'); + const row = await findSession(id, signal); + const data = match + ? await waitCall(row.id, { match, from }, 'wait-output', timeoutMs, signal) + : await waitCall(row.id, { until: until!.join(',') }, 'wait', timeoutMs, signal); + const outcome = waitOutcome(data.wait); + const result: Record = { session_id: row.id, outcome, signal: data.wait?.signal ?? null }; + if (data.wait?.snippet) result.snippet = data.wait.snippet; + if (data.limitPaused) result.note = 'The session is paused on a usage limit, so a timeout is expected.'; + else if (outcome === 'timed_out') result.note = 'Not yet: call wait_for_session again to keep waiting.'; + return structuredResult(result); + }, + + async read_output(args, signal) { + const id = readString(args, 'session_id', { required: true })!; + const source = readEnum(args, 'source', ['last_answer', 'conversation', 'terminal'] as const, 'last_answer'); + const tailBytes = readInt(args, 'tail_bytes', 4000, 100, 200_000); + const row = await findSession(id, signal); + const sid = encodeURIComponent(row.id); + if (source === 'terminal') { + const res = await call( + { method: 'GET', path: `/api/v1/sessions/${sid}/terminal`, query: { tail: tailBytes } }, + signal + ); + ensureOk(res); + const buffer = (res.json?.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? ''; + return { text: readableText(tidyTerminal(stripAnsi(buffer)), 'tail') || '(the terminal is empty)' }; + } + const res = await call( + { + method: 'GET', + path: `/api/v1/sessions/${sid}/last-response`, + query: { context: source === 'conversation' ? 'full' : undefined }, + }, + signal + ); + ensureOk(res); + const data = res.json?.data as { text?: string; messages?: Array<{ role: string; text: string }> } | undefined; + const raw = + source === 'conversation' && data?.messages + ? data.messages.map((message) => `${message.role}: ${message.text}`).join('\n\n') + : (data?.text ?? ''); + if (!raw.trim()) { + return { text: 'Nothing to read yet (no answer, or none the server could cut out). Try source terminal.' }; + } + return { text: readableText(raw, source === 'conversation' ? 'tail' : 'head') }; + }, + + async interrupt_session(args, signal) { + const id = readString(args, 'session_id', { required: true })!; + const row = await findSession(id, signal); + guardControl(row, 'interrupt'); + const res = await call( + { + method: 'POST', + path: `/api/v1/sessions/${encodeURIComponent(row.id)}/input`, + body: buildInterruptBody(deps.clientId, (deps.now ?? Date.now)()), + }, + signal + ); + ensureOk(res); + return structuredResult({ + session_id: row.id, + outcome: 'escape_sent', + note: 'One Escape does not always land: read_output before the next prompt.', + }); + }, + + async delete_session(args, signal) { + const id = readString(args, 'session_id', { required: true })!; + const row = await findSession(id, signal); + guardControl(row, 'delete'); + const res = await call({ method: 'DELETE', path: `/api/v1/sessions/${encodeURIComponent(row.id)}` }, signal); + ensureOk(res); + deps.started.delete(row.id); + return structuredResult({ + session_id: row.id, + outcome: 'deleted', + note: 'Its case folder stays on disk.', + }); + }, + }; + + return { + has(name: string): boolean { + return Object.prototype.hasOwnProperty.call(tools, name); + }, + async run(name: string, args: Record, signal?: AbortSignal): Promise { + try { + return await tools[name](args, signal); + } catch (err) { + if (err instanceof ToolError) return { text: err.message, isError: true }; + throw err; + } + }, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Protocol +// ───────────────────────────────────────────────────────────────────────────── + +type JsonRpcId = string | number; + +interface JsonRpcMessage { + jsonrpc?: unknown; + id?: unknown; + method?: unknown; + params?: unknown; +} + +export interface McpServerOptions { + config: McpConfig; + /** One JSON-RPC message out (a line on stdout in production). */ + write: (message: Record) => void; + log?: (line: string) => void; + request?: ApiRequest; + /** Run modes start_session offers; defaults to the registry's enabled entries. */ + modes?: readonly string[]; + now?: () => number; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The protocol core, transport-free so tests drive it message by message. Requests run + * concurrently (a long wait must not hold up a list), each answered once, by id. + */ +export function createMcpServer(options: McpServerOptions) { + const { config, write } = options; + const log = options.log ?? (() => {}); + const serverInfo = { name: 'codeman', title: 'Codeman', version: config.version }; + const tools = buildToolDefinitions(options.modes ?? registryModes()); + const runner = createToolRunner({ + config, + request: options.request ?? httpRequest, + started: new Set(), + clientId: `codeman-mcp-${randomBytes(4).toString('hex')}`, + now: options.now, + }); + const inflight = new Map(); + let closed = false; + + function send(message: Record): void { + if (!closed) write({ jsonrpc: '2.0', ...message }); + } + + function sendError(id: JsonRpcId | null, code: number, message: string, data?: unknown): void { + send({ id, error: data === undefined ? { code, message } : { code, message, data } }); + } + + /** A result, decorated for the era the request was made in. */ + function sendResult(id: JsonRpcId, result: Record, modern: boolean): void { + if (!modern) { + send({ id, result }); + return; + } + const meta = isObject(result._meta) ? result._meta : {}; + send({ id, result: { resultType: 'complete', ...result, _meta: { ...meta, [META_SERVER_INFO]: serverInfo } } }); + } + + async function handleRequest(id: JsonRpcId, method: string, params: Record): Promise { + if (method === 'initialize') { + // Legacy handshake: answer with the client's revision when we serve it, else our newest legacy one. + const requested = params.protocolVersion; + const protocolVersion = + typeof requested === 'string' && (LEGACY_PROTOCOL_VERSIONS as readonly string[]).includes(requested) + ? requested + : LEGACY_PROTOCOL_VERSIONS[0]; + sendResult( + id, + { + protocolVersion, + capabilities: { tools: { listChanged: false } }, + serverInfo, + instructions: SERVER_INSTRUCTIONS, + }, + false + ); + return; + } + + const meta = isObject(params._meta) ? params._meta : undefined; + const version = meta?.[META_PROTOCOL_VERSION]; + const modern = version !== undefined; + if (modern) { + if (typeof version !== 'string' || !(MODERN_PROTOCOL_VERSIONS as readonly string[]).includes(version)) { + sendError(id, RPC_ERROR.unsupportedProtocolVersion, 'Unsupported protocol version', { + supported: [...MODERN_PROTOCOL_VERSIONS], + requested: version, + }); + return; + } + if (!isObject(meta?.[META_CLIENT_CAPABILITIES])) { + sendError(id, RPC_ERROR.invalidParams, `Missing _meta["${META_CLIENT_CAPABILITIES}"]`); + return; + } + } + + switch (method) { + case 'server/discover': + sendResult( + id, + { + supportedVersions: [...MODERN_PROTOCOL_VERSIONS], + capabilities: { tools: { listChanged: false } }, + instructions: SERVER_INSTRUCTIONS, + ...CACHE_HINTS, + }, + true + ); + return; + case 'ping': + sendResult(id, {}, modern); + return; + case 'tools/list': + sendResult(id, modern ? { tools, ...CACHE_HINTS } : { tools }, modern); + return; + case 'tools/call': { + const name = params.name; + if (typeof name !== 'string' || !runner.has(name)) { + sendError(id, RPC_ERROR.invalidParams, `Unknown tool: ${String(name)}`); + return; + } + const args = params.arguments === undefined ? {} : params.arguments; + if (!isObject(args)) { + sendError(id, RPC_ERROR.invalidParams, 'arguments must be an object'); + return; + } + const controller = new AbortController(); + inflight.set(id, controller); + try { + const result = await runner.run(name, args, controller.signal); + if (controller.signal.aborted) return; // cancelled: no further message for this id + const payload: Record = { + content: [{ type: 'text', text: result.text }], + isError: Boolean(result.isError), + }; + if (result.structured) payload.structuredContent = result.structured; + sendResult(id, payload, modern); + } catch (err) { + if (controller.signal.aborted) return; + log(`codeman mcp: ${name} failed: ${getErrorMessage(err)}`); + sendError(id, RPC_ERROR.internal, `Internal error in ${name}: ${getErrorMessage(err)}`); + } finally { + inflight.delete(id); + } + return; + } + default: + sendError(id, RPC_ERROR.methodNotFound, `Method not found: ${method}`); + } + } + + function handleNotification(method: string, params: Record): void { + if (method === 'notifications/cancelled') { + const requestId = params.requestId; + if (typeof requestId === 'string' || typeof requestId === 'number') inflight.get(requestId)?.abort(); + } + // notifications/initialized and anything else: nothing to do, and never an answer. + } + + return { + /** One stdin line. Resolves when that message is fully handled. */ + async handleLine(line: string): Promise { + const trimmed = line.trim(); + if (!trimmed) return; + let message: unknown; + try { + message = JSON.parse(trimmed); + } catch { + sendError(null, RPC_ERROR.parse, 'Parse error'); + return; + } + if (!isObject(message)) { + sendError(null, RPC_ERROR.invalidRequest, 'Expected one JSON-RPC message object per line'); + return; + } + const msg = message as JsonRpcMessage; + const params = isObject(msg.params) ? msg.params : {}; + if (typeof msg.method !== 'string') return; // a response to nothing we sent: ignore + const hasId = msg.id !== undefined; + if (!hasId) { + handleNotification(msg.method, params); + return; + } + if (typeof msg.id !== 'string' && typeof msg.id !== 'number') { + sendError(null, RPC_ERROR.invalidRequest, 'Request id must be a string or a number'); + return; + } + if (msg.jsonrpc !== '2.0') { + sendError(msg.id, RPC_ERROR.invalidRequest, 'jsonrpc must be "2.0"'); + return; + } + await handleRequest(msg.id, msg.method, params); + }, + /** Stop: abort every in-flight call and write nothing more. */ + close(): void { + for (const controller of inflight.values()) controller.abort(); + inflight.clear(); + closed = true; + }, + inflightCount(): number { + return inflight.size; + }, + }; +} + +/** + * Serve MCP on this process's stdin/stdout until stdin closes (the client's shutdown + * signal), then abort whatever is still in flight. + */ +export async function runMcpStdio( + config: McpConfig, + io: { stdin: NodeJS.ReadableStream; stdout: NodeJS.WritableStream; stderr: NodeJS.WritableStream } = process +): Promise { + const log = (line: string) => io.stderr.write(`${line}\n`); + const server = createMcpServer({ + config, + write: (message) => io.stdout.write(`${JSON.stringify(message)}\n`), + log, + }); + log( + `codeman mcp ${config.version}: ${config.apiUrl ? `driving ${config.apiUrl}` : `not configured (${config.urlError ?? 'set CODEMAN_API_URL or --url'})`}` + + `${config.selfId ? `, inside session ${config.selfId.slice(0, 8)}` : ''}` + + `${config.allowAnySession ? ', --allow-any-session' : ''}` + ); + const lines = createInterface({ input: io.stdin, crlfDelay: Infinity }); + lines.on('line', (line) => { + void server.handleLine(line).catch((err: unknown) => log(`codeman mcp: ${getErrorMessage(err)}`)); + }); + await new Promise((resolve) => lines.on('close', () => resolve())); + server.close(); +} diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts new file mode 100644 index 00000000..9facc51a --- /dev/null +++ b/test/mcp-server.test.ts @@ -0,0 +1,533 @@ +/** + * @fileoverview `codeman mcp` (src/mcp-server.ts): the dual-era MCP protocol core, the + * seven tools against a recording fake transport, and one end-to-end run of the real + * CLI over stdio against a local fake Codeman. + * + * Pinned invariants: no guessed URL (unconfigured = tools answer how to configure, list + * still works); prompting/interrupting/deleting only sessions this server started (or + * the calling session's own children, or with --allow-any-session), never itself; + * control bytes refused before any request; tool failures are `isError` results; + * cancellation aborts the HTTP call and sends nothing for that id. + * + * The protocol and tool tests need no port. The stdio test binds a raw http server on + * port 0 and reads `address().port`. + */ + +import { describe, it, expect } from 'vitest'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import type { AddressInfo } from 'node:net'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + buildToolDefinitions, + createMcpServer, + createToolRunner, + DEFAULT_WAIT_MS, + MAX_OUTPUT_CHARS, + resolveMcpConfig, + RPC_ERROR, + type McpConfig, +} from '../src/mcp-server.js'; +import type { ApiRequest, ApiResponse, RequestOptions } from '../src/cli-agent.js'; + +const MODERN_META = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, +}; + +const SELF = 'aaaaaaaa-0000-0000-0000-000000000000'; +const MINE = 'bbbbbbbb-1111-1111-1111-111111111111'; +const USERS = 'cccccccc-2222-2222-2222-222222222222'; +const CHILD = 'dddddddd-3333-3333-3333-333333333333'; + +function config(overrides: Partial = {}): McpConfig { + return { apiUrl: 'http://127.0.0.1:9', allowAnySession: false, version: '0.0.0-test', ...overrides }; +} + +function ok(data: unknown): ApiResponse { + return { status: 200, json: { success: true, data }, text: JSON.stringify({ success: true, data }) }; +} + +interface FakeCall extends RequestOptions { + url: string; +} + +/** A fake Codeman: a session list plus per-route answers; records every call. */ +function fakeCodeman( + rows: Array> = [ + { id: SELF, name: 'lead', mode: 'claude', status: 'busy' }, + { id: USERS, name: 'w1-myapp', mode: 'claude', status: 'idle' }, + { id: CHILD, name: 'child', mode: 'codex', status: 'idle', parentSessionId: SELF }, + ], + answers: Record ApiResponse | Promise> = {} +): { request: ApiRequest; calls: FakeCall[] } { + const calls: FakeCall[] = []; + const request: ApiRequest = async (ctx, options) => { + const call = { ...options, url: ctx.apiUrl }; + calls.push(call); + const key = `${options.method} ${options.path}`; + for (const [pattern, answer] of Object.entries(answers)) { + if (key === pattern || (pattern.endsWith('*') && key.startsWith(pattern.slice(0, -1)))) return answer(call); + } + if (key === 'GET /api/v1/sessions') return ok(rows); + if (key === 'POST /api/v1/quick-start') { + rows.push({ id: MINE, name: 'worker', mode: String(options.body?.mode ?? 'claude'), status: 'idle' }); + return ok({ + sessionId: MINE, + caseName: options.body?.caseName, + casePath: `/cases/${String(options.body?.caseName)}`, + }); + } + if (key.endsWith('/wait-output')) return ok({ wait: { matched: true, match: options.query?.match } }); + if (key.endsWith('/input')) + return ok({ delivered: true, wait: { signal: 'stop', until: ['stop', 'idle', 'exit'] } }); + if (options.method === 'DELETE') return ok({}); + return { status: 404, json: { success: false, error: 'not found', errorCode: 'NOT_FOUND' }, text: '' }; + }; + return { request, calls }; +} + +function runner(cfg: McpConfig, fake = fakeCodeman()) { + return { + ...fake, + tools: createToolRunner({ config: cfg, request: fake.request, started: new Set(), clientId: 'c', now: () => 1 }), + }; +} + +/** Drive the protocol core; returns every message it wrote. */ +function server(cfg: McpConfig = config(), fake = fakeCodeman()) { + const out: Array> = []; + const srv = createMcpServer({ + config: cfg, + write: (m) => out.push(m), + request: fake.request, + modes: ['claude', 'codex', 'shell'], + }); + const send = async (message: unknown) => srv.handleLine(JSON.stringify(message)); + return { srv, out, send, ...fake }; +} + +describe('protocol: both eras', () => { + it('answers a legacy initialize with the requested revision, or its newest legacy one', async () => { + const { out, send } = server(); + await send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {} }, + }); + await send({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2030-01-01' } }); + const [first, second] = out as Array<{ result: Record }>; + expect(first.result.protocolVersion).toBe('2025-06-18'); + expect(first.result.serverInfo).toMatchObject({ name: 'codeman' }); + expect(first.result.capabilities).toEqual({ tools: { listChanged: false } }); + expect(first.result.resultType).toBeUndefined(); + expect(second.result.protocolVersion).toBe('2025-11-25'); + }); + + it('serves server/discover with resultType, supported versions and serverInfo in _meta', async () => { + const { out, send } = server(); + await send({ jsonrpc: '2.0', id: 'd', method: 'server/discover', params: { _meta: MODERN_META } }); + const result = (out[0] as { result: Record }).result; + expect(result.resultType).toBe('complete'); + expect(result.supportedVersions).toEqual(['2026-07-28']); + expect((result._meta as Record)['io.modelcontextprotocol/serverInfo']).toMatchObject({ + name: 'codeman', + }); + }); + + it('rejects an unsupported modern version with -32022 listing what it supports', async () => { + const { out, send } = server(); + await send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/list', + params: { _meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': '1999-01-01' } }, + }); + expect(out[0]).toMatchObject({ + id: 3, + error: { + code: RPC_ERROR.unsupportedProtocolVersion, + data: { supported: ['2026-07-28'], requested: '1999-01-01' }, + }, + }); + }); + + it('rejects a modern request without clientCapabilities as invalid params', async () => { + const { out, send } = server(); + await send({ + jsonrpc: '2.0', + id: 4, + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } }, + }); + expect(out[0]).toMatchObject({ id: 4, error: { code: RPC_ERROR.invalidParams } }); + }); + + it('lists the same seven tools in a fixed order, decorated for the era', async () => { + const { out, send } = server(); + await send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + await send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: MODERN_META } }); + const legacy = (out[0] as { result: { tools: Array<{ name: string }>; resultType?: string } }).result; + const modern = (out[1] as { result: { tools: Array<{ name: string }>; resultType?: string } }).result; + const names = [ + 'list_sessions', + 'start_session', + 'send_prompt', + 'wait_for_session', + 'read_output', + 'interrupt_session', + 'delete_session', + ]; + expect(legacy.tools.map((t) => t.name)).toEqual(names); + expect(modern.tools.map((t) => t.name)).toEqual(names); + expect(legacy.resultType).toBeUndefined(); + expect(modern.resultType).toBe('complete'); + }); + + it('puts the caching hints the modern revision requires on tools/list and server/discover', async () => { + // Claude Code 2.1.296 validates these and drops every tool when they are missing. + const { out, send } = server(); + await send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: MODERN_META } }); + await send({ jsonrpc: '2.0', id: 2, method: 'server/discover', params: { _meta: MODERN_META } }); + for (const message of out as Array<{ result: { ttlMs?: unknown; cacheScope?: unknown } }>) { + expect(typeof message.result.ttlMs).toBe('number'); + expect(message.result.ttlMs as number).toBeGreaterThanOrEqual(0); + expect(['public', 'private']).toContain(message.result.cacheScope); + } + }); + + it('answers protocol faults with JSON-RPC errors and notifications with nothing', async () => { + const { out, srv, send } = server(); + await srv.handleLine('{not json'); + await send([{ jsonrpc: '2.0', id: 1, method: 'ping' }]); + await send({ jsonrpc: '2.0', id: 2, method: 'resources/list' }); + await send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'rm_rf', arguments: {} } }); + await send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await send({ jsonrpc: '2.0', id: 4, method: 'ping' }); + expect(out.map((m) => (m as { error?: { code: number } }).error?.code ?? 'ok')).toEqual([ + RPC_ERROR.parse, + RPC_ERROR.invalidRequest, + RPC_ERROR.methodNotFound, + RPC_ERROR.invalidParams, + 'ok', + ]); + expect(out[0]).toMatchObject({ id: null }); + }); + + it('a cancelled call aborts its HTTP request and is never answered', async () => { + let seen: AbortSignal | undefined; + const fake = fakeCodeman(undefined, { + 'GET /api/v1/sessions/*': (call) => + new Promise((_resolve, reject) => { + seen = call.signal; + call.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }), + }); + const { srv, out, send } = server(config(), fake); + const pending = send({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'wait_for_session', arguments: { session_id: USERS, until: ['stop'] } }, + }); + await new Promise((r) => setTimeout(r, 20)); + expect(srv.inflightCount()).toBe(1); + await send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 7 } }); + await pending; + expect(seen?.aborted).toBe(true); + expect(out).toEqual([]); + expect(srv.inflightCount()).toBe(0); + }); + + it('a tool failure is an isError result, not a protocol error', async () => { + const { out, send } = server(config({ apiUrl: undefined })); + await send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } }); + const result = (out[0] as { result: { isError: boolean; content: Array<{ text: string }> } }).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('CODEMAN_API_URL'); + }); +}); + +describe('tool definitions', () => { + it('every tool has an object input schema and honest annotations', () => { + for (const tool of buildToolDefinitions(['claude'])) { + expect(tool.inputSchema.type, tool.name).toBe('object'); + expect(tool.inputSchema.additionalProperties, tool.name).toBe(false); + expect(tool.description.length, tool.name).toBeGreaterThan(80); + expect(tool.annotations.openWorldHint).toBe(false); + } + const byName = Object.fromEntries(buildToolDefinitions(['claude']).map((t) => [t.name, t])); + for (const readOnly of ['list_sessions', 'wait_for_session', 'read_output']) { + expect(byName[readOnly].annotations.readOnlyHint, readOnly).toBe(true); + } + expect(byName.delete_session.annotations.destructiveHint).toBe(true); + }); + + it('start_session offers the run modes it is given, defaulting to claude', () => { + const start = buildToolDefinitions(['claude', 'codex']).find((t) => t.name === 'start_session')!; + const mode = (start.inputSchema.properties as Record).mode; + expect(mode.enum).toEqual(['claude', 'codex']); + expect(mode.default).toBe('claude'); + }); +}); + +describe('configuration never guesses a server', () => { + it('takes --url over CODEMAN_API_URL, and neither means unconfigured', () => { + const env = { CODEMAN_API_URL: 'https://127.0.0.1:3000/' }; + expect(resolveMcpConfig({ version: 'x' }, env, () => ({})).apiUrl).toBe('https://127.0.0.1:3000'); + expect(resolveMcpConfig({ url: 'http://10.0.0.2:5000', version: 'x' }, env, () => ({})).apiUrl).toBe( + 'http://10.0.0.2:5000' + ); + expect(resolveMcpConfig({ version: 'x' }, {}, () => ({})).apiUrl).toBeUndefined(); + }); + + it('refuses a URL that is not http(s)', () => { + const cfg = resolveMcpConfig({ url: 'file:///etc/passwd', version: 'x' }, {}, () => ({})); + expect(cfg.apiUrl).toBeUndefined(); + expect(cfg.urlError).toContain('not an http(s) URL'); + }); + + it('knows its own session only inside a Codeman pane', () => { + expect(resolveMcpConfig({ version: 'x' }, { CODEMAN_MUX: '1', CODEMAN_SESSION_ID: SELF }, () => ({})).selfId).toBe( + SELF + ); + expect(resolveMcpConfig({ version: 'x' }, { CODEMAN_SESSION_ID: SELF }, () => ({})).selfId).toBeUndefined(); + }); + + it('an unconfigured tool call makes no request at all', async () => { + const { tools, calls } = runner(config({ apiUrl: undefined })); + const result = await tools.run('start_session', { case: 'x' }); + expect(result.isError).toBe(true); + expect(calls).toEqual([]); + }); +}); + +describe('who may be prompted, interrupted and deleted', () => { + it('refuses a session this server did not start, with how to allow it, and sends nothing', async () => { + const { tools, calls } = runner(config()); + for (const [tool, args] of [ + ['send_prompt', { session_id: USERS, text: 'hi' }], + ['interrupt_session', { session_id: USERS }], + ['delete_session', { session_id: USERS }], + ] as const) { + const result = await tools.run(tool, args); + expect(result.isError, tool).toBe(true); + expect(result.text).toContain('--allow-any-session'); + } + expect(calls.filter((c) => c.method !== 'GET')).toEqual([]); + }); + + it('a session it started is fair game, and leaves the allowlist once deleted', async () => { + const { tools, calls } = runner(config()); + const started = await tools.run('start_session', { case: 'review-1', mode: 'codex' }); + expect(started.structured?.session_id).toBe(MINE); + expect((await tools.run('send_prompt', { session_id: MINE, text: 'go', wait: false })).isError).toBe(false); + expect((await tools.run('delete_session', { session_id: MINE.slice(0, 8) })).isError).toBe(false); + expect(calls.some((c) => c.method === 'DELETE' && c.path === `/api/v1/sessions/${MINE}`)).toBe(true); + expect((await tools.run('send_prompt', { session_id: MINE, text: 'again' })).isError).toBe(true); + }); + + it('inside Codeman, the calling session can drive its own children', async () => { + const { tools } = runner(config({ selfId: SELF })); + expect((await tools.run('send_prompt', { session_id: CHILD, text: 'go', wait: false })).isError).toBe(false); + expect((await tools.run('send_prompt', { session_id: USERS, text: 'go', wait: false })).isError).toBe(true); + }); + + it('--allow-any-session opens every session except its own', async () => { + const { tools } = runner(config({ selfId: SELF, allowAnySession: true })); + expect((await tools.run('send_prompt', { session_id: USERS, text: 'go', wait: false })).isError).toBe(false); + const self = await tools.run('delete_session', { session_id: SELF }); + expect(self.isError).toBe(true); + expect(self.text).toContain('the session this server runs in'); + }); + + it('list_sessions marks the caller and what it may control', async () => { + const { tools } = runner(config({ selfId: SELF })); + const sessions = (await tools.run('list_sessions', {})).structured?.sessions as Array>; + expect(sessions.map((s) => [s.short_id, s.you, s.controllable])).toEqual([ + ['aaaaaaaa', true, false], + ['cccccccc', false, false], + ['dddddddd', false, true], + ]); + }); +}); + +describe('the tools', () => { + it('start_session posts quick-start with lineage and the scratch label, then waits for the ready mark', async () => { + const { tools, calls } = runner(config({ selfId: SELF })); + const result = await tools.run('start_session', { case: 'review-1', mode: 'claude', name: 'rev' }); + const post = calls.find((c) => c.path === '/api/v1/quick-start')!; + expect(post.body).toEqual({ caseName: 'review-1', mode: 'claude', parentSessionId: SELF, sessionName: 'rev' }); + expect(post.headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-mcp' }); + const ready = calls.find((c) => c.path.endsWith('/wait-output'))!; + expect(ready.query?.timeout).toBe(DEFAULT_WAIT_MS); + expect(result.structured).toMatchObject({ session_id: MINE, ready: true }); + }); + + it('send_prompt refuses a newline or control byte before any request', async () => { + const { tools, calls } = runner(config()); + for (const text of ['line one\nline two', 'esc \u001b[A', 'tab\there']) { + expect((await tools.run('send_prompt', { session_id: USERS, text })).isError).toBe(true); + } + expect(calls).toEqual([]); + }); + + it('send_prompt says when "finished" is definite and when it is a quiet-screen guess', async () => { + const quiet = fakeCodeman(undefined, { + 'POST /api/v1/sessions/*': () => ok({ delivered: true, wait: { signal: 'idle', until: ['idle', 'exit'] } }), + }); + const { tools } = runner(config({ allowAnySession: true }), quiet); + const guessed = await tools.run('send_prompt', { session_id: USERS, text: 'go' }); + expect(guessed.structured).toMatchObject({ outcome: 'finished', signal: 'idle' }); + expect(String(guessed.structured?.note)).toContain('not definite'); + + const definite = runner(config({ allowAnySession: true })); + const finished = await definite.tools.run('send_prompt', { session_id: USERS, text: 'go' }); + expect(String(finished.structured?.note)).toContain('finished its turn'); + const input = definite.calls.find((c) => c.path.endsWith('/input'))!; + expect(input.body).toMatchObject({ input: 'go\r', useMux: true, wait: true, waitTimeout: DEFAULT_WAIT_MS }); + }); + + it('send_prompt reports a still-working timeout and a dead pane distinctly', async () => { + const slow = fakeCodeman(undefined, { + 'POST /api/v1/sessions/*': () => ok({ delivered: true, wait: { signal: null, timedOut: true } }), + }); + expect( + ( + await runner(config({ allowAnySession: true }), slow).tools.run('send_prompt', { + session_id: USERS, + text: 'go', + }) + ).structured + ).toMatchObject({ outcome: 'still_working' }); + const dead = fakeCodeman(undefined, { 'POST /api/v1/sessions/*': () => ok({ delivered: false }) }); + const result = await runner(config({ allowAnySession: true }), dead).tools.run('send_prompt', { + session_id: USERS, + text: 'go', + }); + expect(result.isError).toBe(true); + expect(result.structured).toMatchObject({ outcome: 'not_delivered' }); + }); + + it('wait_for_session needs exactly one of until and match, and passes a marker literally', async () => { + const { tools, calls } = runner(config()); + expect((await tools.run('wait_for_session', { session_id: USERS })).isError).toBe(true); + expect((await tools.run('wait_for_session', { session_id: USERS, until: ['stop'], match: 'X' })).isError).toBe( + true + ); + const matched = await tools.run('wait_for_session', { session_id: USERS, match: 'DONE_7f3a' }); + expect(matched.structured).toMatchObject({ outcome: 'matched' }); + expect(calls.at(-1)?.query).toMatchObject({ match: 'DONE_7f3a', from: 'buffer', timeout: DEFAULT_WAIT_MS }); + }); + + it('read_output strips the terminal of escape codes and keeps its tail under the cap, as text only', async () => { + const huge = `\u001b[31mred\u001b[0m${'x'.repeat(MAX_OUTPUT_CHARS + 500)}END`; + const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ terminalBuffer: huge }) }); + const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS, source: 'terminal' }); + const [note, body] = [ + result.text.slice(0, result.text.indexOf('\n')), + result.text.slice(result.text.indexOf('\n') + 1), + ]; + expect(note).toBe(`[showing the last ${MAX_OUTPUT_CHARS} of ${MAX_OUTPUT_CHARS + 506} characters]`); + expect(body.length).toBe(MAX_OUTPUT_CHARS); + expect(body.endsWith('END')).toBe(true); + expect(result.text).not.toContain('\u001b'); + // No structuredContent: a client that prefers it would show the model metadata, not + // the output (measured with Claude Code, a terminal read came back "empty"). + expect(result.structured).toBeUndefined(); + }); + + it('read_output tidies a pane capture: LF line ends, blank runs collapsed, no trailing blanks', async () => { + const pane = 'one\r\ntwo \r\n\r\n\r\n\r\nthree\r\n\r\n\r\n'; + const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ terminalBuffer: pane }) }); + const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS, source: 'terminal' }); + expect(result.text).toBe('one\ntwo\n\nthree'); + }); + + it('read_output says what to try when there is no answer yet', async () => { + const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ text: '' }) }); + const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS }); + expect(result.text).toContain('source terminal'); + }); + + it('refuses a session id shorter than 8 characters and an ambiguous prefix', async () => { + const twins = [{ id: 'eeeeeeee-1111-1111-1111-111111111111' }, { id: 'eeeeeeee-2222-2222-2222-222222222222' }]; + const { tools } = runner(config(), fakeCodeman(twins)); + expect((await tools.run('read_output', { session_id: 'eeee' })).text).toContain('shorter than 8'); + expect((await tools.run('read_output', { session_id: 'eeeeeeee' })).text).toContain('ambiguous'); + }); +}); + +describe('codeman mcp over real stdio', () => { + it('handshakes and lists sessions from a live (fake) Codeman', async () => { + const api = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/api/v1/sessions') { + res.end(JSON.stringify({ success: true, data: [{ id: USERS, name: 'w1', mode: 'claude', status: 'idle' }] })); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ success: false, error: 'nope' })); + } + }); + await new Promise((resolve) => api.listen(0, '127.0.0.1', resolve)); + const port = (api.address() as AddressInfo).port; + try { + const env = { ...process.env, CODEMAN_API_URL: `http://127.0.0.1:${port}` }; + delete env.CODEMAN_MUX; + delete env.CODEMAN_SESSION_ID; + const child = spawn('npx', ['tsx', 'src/index.ts', 'mcp'], { cwd: process.cwd(), env }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + const lines = () => stdout.split('\n').filter(Boolean); + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {} } })}\n` + ); + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } })}\n` + ); + const deadline = Date.now() + 25_000; + while (lines().length < 2 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100)); + child.stdin.end(); + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + + const messages = lines().map((line) => JSON.parse(line) as Record); + expect(messages.map((m) => m.id)).toEqual([1, 2]); + const listed = (messages[1].result as { structuredContent: { sessions: Array<{ id: string }> } }) + .structuredContent; + expect(listed.sessions.map((s) => s.id)).toEqual([USERS]); + expect(stderr).toContain(`driving http://127.0.0.1:${port}`); + expect(exitCode).toBe(0); + } finally { + await new Promise((resolve) => api.close(() => resolve())); + } + }, 40_000); +}); + +describe('the MCP registry manifest (config/mcp-registry/server.json)', () => { + const manifest = JSON.parse(readFileSync(join(process.cwd(), 'config', 'mcp-registry', 'server.json'), 'utf-8')); + const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')); + + it("carries the name package.json's mcpName declares (the registry's ownership check)", () => { + expect(manifest.name).toBe(pkg.mcpName); + expect(manifest.description.length).toBeLessThanOrEqual(100); + }); + + it('runs this npm package as ` mcp` over stdio, at the package version (sync-plugin.mjs keeps it)', () => { + expect(manifest.version).toBe(pkg.version); + const [npm] = manifest.packages; + expect(npm).toMatchObject({ + registryType: 'npm', + identifier: pkg.name, + version: pkg.version, + transport: { type: 'stdio' }, + packageArguments: [{ type: 'positional', value: 'mcp' }], + }); + const env = Object.fromEntries(npm.environmentVariables.map((v: { name: string }) => [v.name, v])); + expect(env.CODEMAN_API_URL.isRequired).toBe(true); + expect(env.CODEMAN_PASSWORD.isSecret).toBe(true); + }); +});