mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 11:19:42 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c041a418e0 | ||
|
|
aa3730aace | ||
|
|
6234aae919 | ||
|
|
34439d18e5 | ||
|
|
45a205b4e5 |
@@ -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`.
|
||||
|
||||
@@ -736,7 +736,7 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
||||
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
|
||||
</p>
|
||||
|
||||
#### Step 1: install it
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -87,6 +87,93 @@ the HTTP status.
|
||||
|
||||
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Saved jobs and their launch history are separate from the legacy `/api/scheduled`
|
||||
duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
|
||||
is the unversioned alias. These routes use the response envelope above; the table
|
||||
lists the value inside `data` on success.
|
||||
|
||||
| Method | Path | Request body | Response `data` |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
|
||||
| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
|
||||
| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
|
||||
| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
|
||||
| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
|
||||
| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
|
||||
| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
|
||||
| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
|
||||
| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
|
||||
|
||||
### Job request fields
|
||||
|
||||
The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
|
||||
`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
|
||||
are required according to the selected prompt and schedule:
|
||||
|
||||
| Field | Type / validation |
|
||||
| --- | --- |
|
||||
| `name` | String, 1–200 characters |
|
||||
| `agentType` | A supported session mode (including `shell`) |
|
||||
| `workingDir` | Existing, allowed working-directory path |
|
||||
| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
|
||||
| `promptMode` | `inline_text` or `prompt_file_path` |
|
||||
| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
|
||||
| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
|
||||
| `inputMode` | `paste` or `typed` |
|
||||
| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
|
||||
| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
|
||||
| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
|
||||
| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
|
||||
| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
|
||||
| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
|
||||
| `enabled` | Boolean |
|
||||
| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
|
||||
| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
|
||||
| `notes` | Optional string, at most 2000 characters |
|
||||
|
||||
`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
|
||||
When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
|
||||
`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
|
||||
policy, and does not change the schedule. `activeAgents` counts live sessions of
|
||||
the same agent type, excluding sessions created by this job.
|
||||
For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
|
||||
also closes the previous run's session before launching, even if it is still working.
|
||||
|
||||
### Job and run response fields
|
||||
|
||||
`CronJob` contains the request fields plus server-maintained `id`, optional
|
||||
`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
|
||||
`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
|
||||
milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
|
||||
`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
|
||||
|
||||
`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
|
||||
`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
|
||||
`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
|
||||
Run times are also Unix milliseconds. Status is one of `created`,
|
||||
`session_started`, `prompt_sent`, `failed`, or `skipped`.
|
||||
|
||||
Prompt delivery continues asynchronously after session launch, so `Run Now` can
|
||||
return `session_started` before the prompt is sent. Read run history for subsequent
|
||||
updates, but do not assume a terminal status will follow: if the session is closed
|
||||
during the readiness wait or the server restarts before delivery, the run can remain
|
||||
`session_started` indefinitely with `finishedAt: null`.
|
||||
`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
|
||||
of the agent's task**; `prompt_sent` does not prove that the task succeeded.
|
||||
|
||||
In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
|
||||
or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
|
||||
`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
|
||||
launch-command job without the required privilege grant. Invalid definitions or
|
||||
working directories return `INVALID_INPUT`; launch/delivery failures are recorded
|
||||
on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
|
||||
|
||||
See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
|
||||
See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
|
||||
`cron:runUpdated` SSE events.
|
||||
|
||||
## Long-polling (agent wait)
|
||||
|
||||
Three calls block until something happens instead of answering immediately. They
|
||||
@@ -470,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
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
|
||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
|
||||
- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
|
||||
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
||||
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
||||
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
||||
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
### In the browser
|
||||
|
||||
1. Click **⏰ Cron** in the header.
|
||||
1. Click **⏰ Cron** in the bottom toolbar.
|
||||
2. Click **+ New Job**.
|
||||
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
||||
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
+13
-5
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
|
||||
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
|
||||
dependencies and open a PR.*
|
||||
|
||||
The ⏰ **Cron** header button is opt-in. Turn it on in
|
||||
**App Settings → Header & Panels**.
|
||||
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
|
||||
**App Settings → Header & Panels → Scheduling**.
|
||||
|
||||
## Creating a job
|
||||
|
||||
@@ -97,10 +97,18 @@ The skip policy has the details you would want it to have:
|
||||
Every fire is recorded per job, with a status:
|
||||
|
||||
| Status | Meaning |
|
||||
| --------- | -------------------------------------------------------------------- |
|
||||
| `created` | The run started and a session was created. |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| `created` | The run record was created; the session has not started yet. |
|
||||
| `session_started` | The session started; prompt delivery is still pending. |
|
||||
| `prompt_sent` | The prompt was sent to the session. |
|
||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||
| `failed` | The prompt could not be resolved, or the working directory was gone. |
|
||||
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
|
||||
|
||||
History records whether the session started and the prompt was delivered, **not whether
|
||||
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
|
||||
If the session is closed during the readiness wait or the server restarts before
|
||||
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
|
||||
clients must not poll forever waiting for a terminal status.
|
||||
|
||||
The schedule is advanced **before** the session launches, so a slow start cannot cause the
|
||||
same job to re-trigger.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,32 @@ Roughly 235 handlers across 26 route modules. By domain:
|
||||
|
||||
Each route module documents its own endpoints in its file header.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
|
||||
versioned endpoints are:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/cron/jobs` | List jobs |
|
||||
| POST | `/api/v1/cron/jobs` | Create a job |
|
||||
| GET | `/api/v1/cron/jobs/:id` | Read a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
|
||||
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
|
||||
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
|
||||
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
|
||||
| GET | `/api/v1/cron/runs` | Read all accessible run history |
|
||||
|
||||
Responses use the envelope above: job lists/history have arrays in `data`, a
|
||||
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
|
||||
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
|
||||
`session_started` and `prompt_sent` describe launch/delivery, not task success.
|
||||
|
||||
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
|
||||
lists all request and response fields, validation, and ownership restrictions.
|
||||
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
|
||||
|
||||
## Long-polling instead of polling
|
||||
|
||||
Three calls block until something happens, so an agent driving Codeman from a shell can wait
|
||||
|
||||
@@ -258,7 +258,7 @@ Two extras depending on the device:
|
||||
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
|
||||
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
|
||||
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
|
||||
| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
||||
| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
||||
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
|
||||
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
|
||||
| File Viewer | Header | [Working With Files](Working-With-Files) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+7
-5
@@ -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',
|
||||
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,
|
||||
};
|
||||
// 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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
/** @fileoverview Guards cron docs against drift from routes, schema, statuses, and UI entry points. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CronJobSchema } from '../src/web/schemas.js';
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const reference = read('docs/api-reference.md');
|
||||
const wiki = read('docs/wiki/HTTP-API.md');
|
||||
const guide = read('docs/wiki/Cron-Jobs.md');
|
||||
|
||||
const cronSection = (text: string) => text.split('## Cron jobs\n')[1]?.split('\n## ')[0] ?? '';
|
||||
|
||||
describe('cron documentation', () => {
|
||||
it('documents every cron route in both API references', () => {
|
||||
const routes = [...read('src/web/routes/cron-routes.ts').matchAll(/app\.(get|post|put|delete)\('([^']+)'/g)];
|
||||
expect(routes).toHaveLength(9);
|
||||
for (const [, method, path] of routes) {
|
||||
const row = `| ${method.toUpperCase()} | \`${path.replace('/api/', '/api/v1/')}\` |`;
|
||||
expect(cronSection(reference)).toContain(row);
|
||||
expect(cronSection(wiki)).toContain(row);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents every accepted job field and validates the guide example', () => {
|
||||
for (const field of Object.keys(CronJobSchema.shape)) {
|
||||
expect(cronSection(reference)).toContain(`| \`${field}\` |`);
|
||||
}
|
||||
const example = guide.match(/-d '(\{[\s\S]*?\})'/)?.[1];
|
||||
expect(example).toBeDefined();
|
||||
expect(CronJobSchema.safeParse(JSON.parse(example!)).success).toBe(true);
|
||||
});
|
||||
|
||||
it('documents all run statuses without treating prompt delivery as task success', () => {
|
||||
const statusType = read('src/types/cron.ts').match(/export type CronJobRunStatus = ([^;]+);/)?.[1];
|
||||
expect(statusType).toBeDefined();
|
||||
for (const [, status] of statusType!.matchAll(/'([^']+)'/g)) {
|
||||
expect(guide).toContain(`| \`${status}\``);
|
||||
expect(cronSection(reference)).toContain(`\`${status}\``);
|
||||
}
|
||||
expect(guide.replace(/\s+/g, ' ')).toContain("not whether the agent's task succeeded");
|
||||
expect(guide).toContain('bottom toolbar');
|
||||
expect(guide).toContain('App Settings → Header & Panels → Scheduling');
|
||||
});
|
||||
|
||||
it('keeps linked pages consistent and explains non-terminal launch history', () => {
|
||||
expect(read('docs/wiki/The-Dashboard.md')).toMatch(/\| Cron\s*\| Bottom toolbar/);
|
||||
expect(read('docs/cron-guide.md')).not.toMatch(/Cron\*\* (?:button )?in the header/);
|
||||
for (const text of [reference, guide]) {
|
||||
const normalized = text.replace(/\s+/g, ' ');
|
||||
expect(normalized).toContain('session is closed during the readiness wait');
|
||||
expect(normalized).toContain('server restarts before delivery');
|
||||
expect(normalized).toContain('`session_started` indefinitely with `finishedAt: null`');
|
||||
}
|
||||
expect(reference).toContain("also closes the previous run's session before launching");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user