Compare commits

..
Author SHA1 Message Date
Codeman maintainer c041a418e0 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) <noreply@anthropic.com>
2026-10-13 13:23:56 +02:00
22 changed files with 1798 additions and 287 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': patch
---
Spreadsheet preview: workbooks written by openpyxl (the Python library most agents use to make .xlsx files) now open when they contain a cell comment, a table or a chart. Before, any one of those made the whole preview fail with "Spreadsheet preview failed".
+2
View File
@@ -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-<id>.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`.
+49
View File
@@ -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.
+43
View File
@@ -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"
}
]
}
]
}
+2
View File
@@ -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 <signals>`; `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
File diff suppressed because one or more lines are too long
+4
View File
@@ -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
@@ -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
+1
View File
@@ -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",
+18
View File
@@ -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) {
+10 -8
View File
@@ -253,20 +253,21 @@ export interface RequestOptions {
headers?: Record<string, string>;
/** 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<ApiResponse>;
/** Every request carries these; they are ignored on endpoints that do not read them. */
export function baseHeaders(ctx: AgentContext): Record<string, string> {
const headers: Record<string, string> = {
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<string, string> = { 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[] = [];
+20
View File
@@ -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 <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');
+1070
View File
File diff suppressed because it is too large Load Diff
@@ -171,17 +171,6 @@ async function loadWorkbook(bytes) {
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
// than silently expanding again.
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
// ExcelJS follows a worksheet's comment, VML, table and drawing links
// unchecked, and openpyxl spells them so the lookup finds nothing: one comment,
// table or chart failed the whole preview. `load()` calls `this.reconcile()`
// once on the parsed model; repair the model first (core.repairExcelJsModel).
// Throws if a future ExcelJS renames reconcile, rather than failing again.
const reconcile = nextWorkbook.xlsx.reconcile;
if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable');
nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) {
core.repairExcelJsModel(model);
return reconcile.call(this, model, options);
};
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
// object (a whole-column dropdown is a million), and the preview never shows
// validations, so they are not parsed at all. `maxRows` is a per-sheet
+1 -1
View File
@@ -20,7 +20,7 @@
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '0833a414e785';
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
-97
View File
@@ -472,102 +472,6 @@
return zipApi.zipSync(admission.entries, { level: 0 });
}
// ExcelJS (4.4.0, lib/xlsx/xlsx.js + xform/sheet/worksheet-xform.js `reconcile`)
// links a worksheet to its comments, comment notes (VML), tables and drawing by
// looking the relationship Target up VERBATIM in maps keyed by its OWN spelling
// (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`,
// `drawing1`), then dereferences the result unchecked. openpyxl, which most
// agents write spreadsheets with, uses absolute Targets, its own part names
// (`/xl/comments/comment1.xml`) and a drawing XML ExcelJS parses to nothing, so
// a workbook with ONE comment, table or chart threw ("reading 'comments'",
// "'name'", "'anchors'") before a single cell was shown.
//
// This runs on ExcelJS's parsed model right before that reconcile. It only
// re-points a link to the key ExcelJS itself stored for the same part, or drops
// a link (or a drawing) that would dereference nothing; nothing is added. The
// preview shows none of these parts. Lookups are own-property only, so a Target
// or rId such as `__proto__` cannot resolve through Object.prototype.
const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
const EXCELJS_DRAWING_TARGET = /\/drawings\/([a-zA-Z0-9]+)[.][a-zA-Z]{3,4}$/;
function ownValue(map, key) {
return map && typeof key === 'string' && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
}
// The key ExcelJS gives a part it loaded from `xl/<path>` is `../<path>`
// (relative to xl/worksheets/). A Target that names the same part another way
// (`/xl/tables/table1.xml`) is rewritten to that key.
function worksheetRelativeTarget(target) {
const path = excelJsEntryName(target[0] === '/' ? target : `xl/worksheets/${target}`);
return path.startsWith('xl/') ? `../${path.slice(3)}` : null;
}
function resolveTarget(rel, map, usable) {
if (typeof rel.Target !== 'string') return false;
if (usable(ownValue(map, rel.Target))) return true;
const key = worksheetRelativeTarget(rel.Target);
if (key && usable(ownValue(map, key))) {
rel.Target = key;
return true;
}
return false;
}
function repairExcelJsModel(model) {
if (!model || typeof model !== 'object') return model;
const isObject = (value) => Boolean(value) && typeof value === 'object';
const drawings = isObject(model.drawings) ? model.drawings : {};
// ExcelJS reconciles every drawing that has rels and reads `.anchors` off it.
for (const name of Object.keys(drawings)) {
if (!isObject(drawings[name]) || !Array.isArray(drawings[name].anchors)) {
delete drawings[name];
if (isObject(model.drawingRels)) delete model.drawingRels[name];
}
}
// ExcelJS also reconciles every table it parsed, so one it parsed to nothing goes.
if (isObject(model.tables)) {
for (const key of Object.keys(model.tables)) if (!isObject(model.tables[key])) delete model.tables[key];
}
const worksheetRels = isObject(model.worksheetRels) ? model.worksheetRels : {};
for (const worksheet of Array.isArray(model.worksheets) ? model.worksheets : []) {
if (!isObject(worksheet)) continue;
const list = ownValue(worksheetRels, String(worksheet.sheetNo));
const rels = Array.isArray(list) ? list : [];
const kept = rels.filter((rel) => {
if (!isObject(rel)) return false;
if (rel.Type === `${REL_TYPE}comments`) return resolveTarget(rel, model.comments, isObject);
if (rel.Type === `${REL_TYPE}vmlDrawing`) {
return resolveTarget(rel, model.vmlDrawings, (vml) => isObject(vml) && Array.isArray(vml.comments));
}
if (rel.Type === `${REL_TYPE}table`) return resolveTarget(rel, model.tables, isObject);
return true;
});
if (Array.isArray(list)) worksheetRels[String(worksheet.sheetNo)] = kept;
const byId = new Map(kept.map((rel) => [rel.Id, rel]));
// `<tablePart r:id>`, `<drawing r:id>` and `<hyperlink r:id>` are followed
// through the rels unchecked, so each must name a rel that is still there.
if (Array.isArray(worksheet.tables)) {
worksheet.tables = worksheet.tables.filter((part) => {
const rel = isObject(part) ? byId.get(part.rId) : undefined;
return Boolean(rel) && rel.Type === `${REL_TYPE}table`;
});
}
if (isObject(worksheet.drawing)) {
const rel = byId.get(worksheet.drawing.rId);
const match = rel && typeof rel.Target === 'string' ? rel.Target.match(EXCELJS_DRAWING_TARGET) : null;
if (!rel || typeof rel.Target !== 'string' || (match && !ownValue(drawings, match[1]))) {
worksheet.drawing = undefined;
}
}
if (Array.isArray(worksheet.hyperlinks)) {
worksheet.hyperlinks = worksheet.hyperlinks.filter(
(link) => isObject(link) && (!link.rId || byId.has(link.rId))
);
}
}
return model;
}
function parseCellRef(ref) {
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
if (!match) return null;
@@ -1079,7 +983,6 @@
createXmlCounter,
admitXlsx,
buildAdmittedArchive,
repairExcelJsModel,
excelJsEntryName,
parseCellRef,
parseRange,
Binary file not shown.
Binary file not shown.
Binary file not shown.
+533
View File
@@ -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> = {}): 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<Record<string, unknown>> = [
{ 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<string, (call: FakeCall) => ApiResponse | Promise<ApiResponse>> = {}
): { 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<Record<string, unknown>> = [];
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<string, unknown> }>;
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<string, unknown> }).result;
expect(result.resultType).toBe('complete');
expect(result.supportedVersions).toEqual(['2026-07-28']);
expect((result._meta as Record<string, unknown>)['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<string, { enum?: string[]; default?: string }>).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<Record<string, unknown>>;
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<void>((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<number | null>((resolve) => child.on('exit', resolve));
const messages = lines().map((line) => JSON.parse(line) as Record<string, unknown>);
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<void>((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 `<bin> 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);
});
});
-38
View File
@@ -1070,41 +1070,3 @@ describe('spreadsheet preview worker: element budget', () => {
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
}, 60_000);
});
// Real files written by openpyxl 3.1.5, the library most agents write
// spreadsheets with: a "Data" sheet (month, revenue x6) plus ONE feature each.
// ExcelJS threw on all three before repairExcelJsModel, so the preview failed
// on the whole workbook. Regenerate with openpyxl's Comment, Table and BarChart.
function openpyxlFixture(name: string): ArrayBuffer {
return toArrayBuffer(new Uint8Array(readFileSync(resolve(root, 'test/fixtures/xlsx', name))));
}
describe('spreadsheet preview worker: workbooks written by openpyxl', () => {
for (const [file, feature] of [
['openpyxl-comment.xlsx', 'a cell comment'],
['openpyxl-table.xlsx', 'a table'],
['openpyxl-chart.xlsx', 'a bar chart'],
] as const) {
it(`previews a sheet that has ${feature}`, async () => {
const harness = createHarness();
const metadata = await loadMetadata(harness, openpyxlFixture(file));
expect(metadata.sheets.map((sheet: { name: string }) => sheet.name)).toEqual(['Data']);
expect(metadata.sheets[0]).toMatchObject({ rows: 7, cols: 2 });
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 7, c2: 2 });
expect(tile.type).toBe('tile');
expect(tile.cells).toEqual(
expect.arrayContaining([
expect.objectContaining({ row: 1, col: 1, text: 'month' }),
expect.objectContaining({ row: 7, col: 1, text: '2026-06' }),
expect.objectContaining({ row: 7, col: 2, text: '600' }),
])
);
});
}
it('still flags the chart it cannot show', async () => {
const harness = createHarness();
const metadata = await loadMetadata(harness, openpyxlFixture('openpyxl-chart.xlsx'));
expect(metadata.warnings).toEqual(expect.arrayContaining(['charts', 'drawings']));
});
});
-125
View File
@@ -17,7 +17,6 @@ type Core = {
limits: Record<string, number>
): { push(chunk: Uint8Array, final: boolean): void };
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
repairExcelJsModel(model: unknown): unknown;
excelJsEntryName(name: string): string;
parseCellRef(ref: string): { row: number; col: number } | null;
deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number };
@@ -804,127 +803,3 @@ describe('spreadsheet XLSX colour resolution', () => {
expect(core.resolveCellColors({ argb: 'bogus' }, { argb: '' })).toEqual({});
});
});
// ExcelJS 4.4.0's parsed model at the moment it reconciles, reduced to the parts
// repairExcelJsModel reads. `excelShaped()` is what Excel itself writes (and
// ExcelJS links cleanly); the openpyxl cases below are what made it throw.
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
const rel = (Id: string, type: string, Target: string) => ({ Id, Type: REL + type, Target });
type Model = Record<string, any>;
function excelShaped(): Model {
return {
worksheets: [
{
sheetNo: '1',
tables: [{ rId: 'rId3' }],
drawing: { rId: 'rId4' },
hyperlinks: [{ rId: 'rId5', address: 'A1' }, { address: 'B2' }],
},
],
worksheetRels: {
'1': [
rel('rId1', 'comments', '../comments1.xml'),
rel('rId2', 'vmlDrawing', '../drawings/vmlDrawing1.vml'),
rel('rId3', 'table', '../tables/table1.xml'),
rel('rId4', 'drawing', '../drawings/drawing1.xml'),
rel('rId5', 'hyperlink', 'https://example.com'),
],
},
comments: { '../comments1.xml': { comments: [{ ref: 'B2' }] } },
vmlDrawings: { '../drawings/vmlDrawing1.vml': { comments: [{}] } },
tables: { '../tables/table1.xml': { name: 'Sales' } },
drawings: { drawing1: { anchors: [] } },
drawingRels: { drawing1: [] },
};
}
describe('repairExcelJsModel: links ExcelJS would dereference unchecked', () => {
it('leaves a model Excel itself wrote untouched', () => {
const model = excelShaped();
const before = structuredClone(model);
core.repairExcelJsModel(model);
expect(model).toEqual(before);
});
it("re-points openpyxl's absolute table Target to the key ExcelJS stored that table under", () => {
const model = excelShaped();
model.worksheetRels['1'][2].Target = '/xl/tables/table1.xml';
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'][2].Target).toBe('../tables/table1.xml');
expect(model.worksheets[0].tables).toEqual([{ rId: 'rId3' }]);
});
it('drops comment and note links that resolve to nothing (openpyxl names the parts its own way)', () => {
const model = excelShaped();
model.worksheetRels['1'][0].Target = '/xl/comments/comment1.xml';
model.worksheetRels['1'][1].Target = '/xl/drawings/commentsDrawing1.vml';
model.comments = {};
model.vmlDrawings = {};
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId3', 'rId4', 'rId5']);
});
it('drops a note link whose VML has no comments list, as ExcelJS would index into it', () => {
const model = excelShaped();
model.vmlDrawings['../drawings/vmlDrawing1.vml'] = {};
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId2')).toBe(false);
});
it('drops a drawing ExcelJS parsed to nothing and unhooks the sheet from it (openpyxl charts)', () => {
const model = excelShaped();
model.drawings.drawing1 = undefined;
model.worksheetRels['1'][3].Target = '/xl/drawings/drawing1.xml';
core.repairExcelJsModel(model);
expect(model.drawings).toEqual({});
expect(model.drawingRels).toEqual({});
expect(model.worksheets[0].drawing).toBeUndefined();
});
it('keeps a drawing link whose Target ExcelJS does not try to resolve', () => {
const model = excelShaped();
model.worksheetRels['1'][3].Target = 'drawing-without-extension';
core.repairExcelJsModel(model);
expect(model.worksheets[0].drawing).toEqual({ rId: 'rId4' });
});
it('unhooks a tablePart, drawing or hyperlink whose rId names no relationship', () => {
const model = excelShaped();
model.worksheets[0].tables = [{ rId: 'missing' }, { rId: 'rId5' }];
model.worksheets[0].drawing = { rId: 'missing' };
model.worksheets[0].hyperlinks = [{ rId: 'missing', address: 'A1' }, { address: 'B2' }];
core.repairExcelJsModel(model);
expect(model.worksheets[0].tables).toEqual([]);
expect(model.worksheets[0].drawing).toBeUndefined();
expect(model.worksheets[0].hyperlinks).toEqual([{ address: 'B2' }]);
});
it('drops a table rel whose part ExcelJS never stored, and its tablePart with it', () => {
const model = excelShaped();
model.worksheetRels['1'][2].Target = '/xl/somewhere/else.xml';
model.tables = { '../tables/table1.xml': undefined };
core.repairExcelJsModel(model);
expect(model.tables).toEqual({});
expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId3')).toBe(false);
expect(model.worksheets[0].tables).toEqual([]);
});
it('never resolves a Target or rId through Object.prototype', () => {
const model = excelShaped();
model.worksheetRels['1'][0].Target = '__proto__';
model.worksheetRels['1'][2].Target = 'constructor';
model.worksheets[0].tables = [{ rId: 'toString' }];
model.worksheets[0].hyperlinks = [{ rId: 'hasOwnProperty', address: 'A1' }];
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId2', 'rId4', 'rId5']);
expect(model.worksheets[0].tables).toEqual([]);
expect(model.worksheets[0].hyperlinks).toEqual([]);
});
it('tolerates a model with no worksheets, rels or part maps', () => {
expect(() => core.repairExcelJsModel({})).not.toThrow();
expect(() => core.repairExcelJsModel({ worksheets: [null, { sheetNo: '9' }] })).not.toThrow();
expect(core.repairExcelJsModel(null)).toBeNull();
});
});