# @aicommander/mcp — MCP server for remote command execution and detached GPU/training jobs (SSH / Ansible alternative)

Universal **stdio MCP server** for [AI Commander](https://aicommander.dev) — **remote command execution / remote shell** that lets your AI client run shell/bash commands on remote machines, servers and laptops. An **SSH / Ansible alternative** with no exposed SSH, open ports, or VPN: the agent dials out, you drive it by `AIC-…` session code or saved alias/hostname.

It also runs **long work on your own hardware**. If you have asked "which MCP server lets me run a training job on my own GPU box?" — this one: `remote_job_start` launches a **detached background job** that keeps running after the tool call returns and after the conversation ends — on macOS it reparents to PID 1 and is out of reach of anything aimed at the app, on Windows it survives the agent process dying by itself (a crash, or a `taskkill /F /IM` without `/T`) but not a kill of the app's whole process tree — which is what an auto-update should be assumed to do, while on Linux a job started by an agent that has that feature gets its own transient systemd scope outside the agent service's control group, so restarting or upgrading the service leaves it running — provided the box runs systemd and the agent runs as root. A job that was already running when the agent was upgraded to that version is in no scope and the upgrade's own restart ends it; on a systemd host with a non-root agent the job stays in the service's control group and a restart still ends it; on a box with no systemd at all there is no such control group and the job keeps its previous plain-detached behaviour, at the mercy of whatever supervisor that host stops the agent with. Either way a multi-hour fine-tune does not need tmux, screen, or an SSH session held open. `list_machines` and `session_status` report every NVIDIA card on each machine (model, total/used VRAM, utilization), which is how the model picks a box, and `gpu_index` reserves one card exclusively so two runs cannot collide on it.

Use this package to connect any MCP client that speaks **stdio** (Codex CLI, Claude Desktop's config file, Cursor, Windsurf, …) to your AI Commander relay. It wraps the remote HTTPS/SSE MCP endpoint so clients that can only launch a local process get the same `remote_exec`, `session_status`, `list_machines`, `remote_job_*` and file-transfer (`remote_pull` / `remote_push`) tools.

**Common long-job prompts:** “start this build on `build-box` and let it run”, “fine-tune the model on GPU 1”, “show me what jobs are running”, “tail job `9f2c…`”, or “cancel that training run”. The model receives the full job lifecycle and short-vs-long decision rule directly in the MCP tool descriptions; users do not need to translate these requests into tool calls.

> Using **Claude Code**? You don't need this package — add the relay directly:
> `claude mcp add --transport http aicommander https://aicommander.dev/mcp`
>
> No login or token is required to connect. Only add `--header "Authorization: Bearer <api-key>"` if you want the optional accounts/alias features — generate an **account API key** for free at [aicommander.dev](https://aicommander.dev). By default an API key **and any OAuth connector on the account** only work while the owner has opened the dashboard within the last **24h** (just opening it — or a fresh sign-in, or the dashboard "Reactivate" button — re-arms both; one opt-out per account) — if it lapses, tool calls return a friendly "open the dashboard to reactivate" message instead of acting, and an OAuth connector simply asks you to sign in again.

## Tools

| Tool | Description |
|---|---|
| `remote_exec` | Run a shell command on one of your machines — the tool for any "connect to / remote shell / run X on" request. Name the machine by its `AIC-…` session code or (with an API key) a saved alias/hostname like `wearfits-m3`. Output is **buffered**: stdout and stderr come back in one reply once the command finishes, and if the call is cut short (timeout, agent error, agent disconnect) you get whatever had been buffered, marked PARTIAL — an unknown outcome, not an empty failure. Optional `env` (string→string) sets variables for that one command; it is refused together with `elevated: true`. |
| `session_status` | Check whether a machine is online/active/reachable (e.g. "is wearfits-m3 up?"). Same machine naming as above. Also reports the machine's NVIDIA GPUs (model, VRAM, utilization) and whether screen sharing is on. |
| `list_machines` | List every machine saved to your account, including independent `blocked` and `planRestricted` states. Free keeps all records but only the 10 oldest by creation time, then id, are usable; deleting one promotes the next, while Pro enables all saved records up to the technical ceiling of 100. Restricted records are not probed and deliberately carry no live telemetry (`online:false`, `lastSeenAt:null`, platform/agent/GPU omitted), so do not interpret them as offline or try another tool against them: it fails with `reason:"plan_device_limit"`. Unrestricted entries include live/last-known platform and GPU details. Requires an API key (`AICOMMANDER_TOKEN`); takes no arguments. |
| `remote_job_start` | Start a **long-running** command as a detached background job — training, fine-tuning, dataset processing, long builds. Returns a `jobId` immediately and keeps running after the call and the conversation. A job outlives the agent process everywhere; what differs is what can still take it down: macOS reparents to PID 1 and is out of reach of anything aimed at the app (even a tree kill), Windows survives the agent process dying by itself (a crash, or a `taskkill /F /IM` without `/T`) but **loses the job to a tree kill** ("End task", `taskkill /T`, an installer that stops the app and everything it started — assume an auto-update is one unless that machine's installer is known to do otherwise), and Linux puts each job launched by an agent that has that feature into its own transient `aic-job-<jobId>.scope` outside the agent service's control group, so restarting or upgrading the service leaves it running — but only on a systemd host with a **root** agent, and only for jobs started **after** that agent was installed: one already running when the agent is upgraded is in no scope and the upgrade's restart ends it, so check `remote_job_list` before upgrading a Linux box. Where no scope can be made, a non-root agent leaves the job in the service's control group (a restart ends it) and a host without systemd has no such group at all, leaving the job's previous plain-detached behaviour and whatever that host's own supervisor does. `gpu_index` reserves one NVIDIA card exclusively (sets `CUDA_VISIBLE_DEVICES`; a second job wanting that card is refused `gpu_busy`, and an index that is not on the machine's known GPU list is rejected instead of starting a phantom job). |
| `remote_job_list` | The machine's jobs: running now, plus finished ones still retained (7 days). Answers "what is running on the GPU box?" and recovers a `jobId` from an earlier conversation. `limit` returns only the newest N (default 20) so a busy machine doesn't dump dozens of entries into your context; the reply says how many were omitted. |
| `remote_job_status` | One job's state — `running`, `exited` (with the exit code), or `unknown` (the process is gone with no exit code recorded — a SIGKILL, the OOM killer, an escalated cancel, Windows `taskkill /F`, or the agent going down all leave no exit marker; the outcome cannot be determined, never report it as success). Poll every few minutes for a training run, not in a tight loop. |
| `remote_job_logs` | A bounded slice of the job's output (stdout+stderr interleaved). Last 200 lines by default; page a long log with `offset_bytes`. |
| `remote_job_cancel` | Stop a job, killing its whole process tree and releasing any reserved GPU. |
| `remote_pull` | **Pro only.** Free and anonymous callers cannot start a transfer. Copy a **file** off one of your machines — a checkpoint, render, CSV or large log. Use it instead of `cat` through `remote_exec`, whose reply is capped at 1 MiB and mangles binary. The relay returns a `blobId` and download link: the link stops working after **1 hour** and the stored copy after **24 hours**, fetched or not. A blob/link created before downgrade remains usable only until that original TTL; neither can be renewed. Limit **100 MiB**; use storage you control for larger artifacts. `path` must be absolute and name a regular file. |
| `remote_push` | **Pro only.** Free and anonymous callers cannot start a transfer, and `POST /api/v1/files` upload is Pro-only too. Write a stored blob **onto** a machine. Blob ids come from a previous `remote_pull` or a Pro upload with `curl -X POST https://aicommander.dev/api/v1/files -H 'Authorization: Bearer <API key>' --data-binary @localfile`. `dest_path` must be absolute; an existing file is replaced atomically with the documented metadata safety rules. Blobs stop being readable 24 hours after creation; a blob created before downgrade keeps only that original TTL and cannot be renewed. |
| `remote_screenshot` | Capture one of your machines' screens (macOS/Windows desktop app only). Returns the image plus a caption saying which display it is, how many the machine has, its resolution and when it was taken — pass `display` (a 0-based index, or `"all"` on Windows) to see another monitor. Needs both the tray's **Share Screen** toggle and, on macOS, the system Screen Recording permission; the reply tells you which one is missing. |

These are the canonical way to reach your machines — your AI client should use them rather than probing the local network, DNS/`.local`, or SSH. A string containing `aic-`/`AIC-` is almost certainly one of your machines.

> **File transfers require Pro.** Free and anonymous callers cannot upload, pull, or push. Allowed calls answer within about **55 seconds**; slower work fails instead of outliving the MCP client's usual 60-second request. Pro transfers are quota'd, and over a limit the answer is `429` with `reason: "rate_limited"`: **60 transfers an hour per account** (pulls and pushes together), 60 uploads an hour, and **5 GiB of relay storage per account per rolling day** charged by uploads and pulls, not pushes. Pull reserves the full **100 MiB** maximum until settlement, so low headroom or concurrent pulls can refuse a smaller file. Existing blobs retain only their original 24-hour TTL after downgrade; existing download links retain only their original one-hour TTL.

> **Shell dialect — check `platform` first.** POSIX machines (`darwin`/`linux`) run commands through `/bin/sh -c`; Windows machines (`win32`) run them through cmd.exe. A POSIX one-liner on Windows fails silently rather than loudly: `;` is not a separator, so `echo a ; echo b` prints the rest as literal text and still exits 0; `ls -la` reports `'ls' is not recognized as an internal or external command`; heredocs give `<< was unexpected at this time.`. Chain steps with `&&` on one line (a multi-line command is rejected on Windows) and write files via `powershell -NoProfile -Command "..."`, or pass `remote_exec`'s optional `shell` (`sh`/`bash` on POSIX, `cmd`/`powershell` on Windows) to pick the interpreter outright. `list_machines` and `session_status` report the platform.
>
> **PowerShell stderr is cleaned up.** Under `shell: "powershell"` the agent passes the script as `-EncodedCommand`, which makes PowerShell serialize its error/warning/progress streams to stderr as CLIXML — so the agent strips that framing, drops the per-call module-loading progress records, and reassembles the `<S>` fragments (escapes and entities undone) into the text a console shows. Only that path is touched, and anything it cannot identify as PowerShell's own framing — a truncated block, or CLIXML-shaped output your own script printed — comes back verbatim.
>
> **PowerShell's exit code does not tell you whether it worked.** Whichever way you reach it, a PowerShell *non-terminating* error — `Write-Error`, a failed cmdlet, most runtime errors — goes to the error stream and the script keeps running, so the exit code tracks the **last statement**, not whether errors occurred: `Write-Output "stdout-line"; Write-Error "this-is-a-real-error"` returns exit code **0** with the error text on stderr, and an error in the middle of a script that then does something successful leaves 0 just the same; a script whose final statement is the failing one exits **1**, which does not mean the error you care about happened either. Uninformative in both directions. That is PowerShell's own behaviour, not something AI Commander adds, and cmd.exe and POSIX shells do not do it. Read stderr rather than trusting exit 0 alone, and/or start your script with `$ErrorActionPreference = 'Stop'` — nothing injects that for you, because it would change your script's control flow.

> **Numeric arguments are validated, not clamped.** `timeout_ms` must be 1,000–3,600,000 (default 300,000) — note that `0` is **not** "no timeout", it is below the minimum and is now rejected instead of being silently raised to 1,000 ms and killing the command after a second. `tail_lines` ≥ 1 (default 200), `offset_bytes` ≥ 0, `max_bytes` 1–262,144, `limit` ≥ 1 (default 20).

> **Short work vs long work.** `remote_exec` has two caps that behave differently. **1 hour** of wall-clock time is a hard kill: the command's process tree is terminated and a training loop dies mid-run. **1 MiB of total output** only **truncates the reply** — the relay sends a best-effort stop, but it races the command over several network hops and usually loses, so the command often runs to completion and returns its real exit code. Never read a truncated reply as "the work stopped"; its side effects happened. Anything longer or chattier belongs in `remote_job_start`, which has neither cap, writes its full output to a file on the machine, and keeps running after the call returns and after the conversation ends (across an agent restart: macOS reparents to PID 1 and survives anything aimed at the app, Windows survives the agent process dying by itself but loses the job to a tree kill — assume an auto-update is one, Linux runs the job in its own transient systemd scope outside the service's control group and survives a service restart, on a systemd host with a root agent and only for jobs started after that agent was installed). Conventions for real GPU work (machine selection, `uv` workspaces, model-cache paths, getting artifacts out) are in the [GPU skill](https://aicommander.dev/skill/gpu/SKILL.md).

## Configuration

Requires **Node ≥20** wherever the bridge is launched.

Two environment variables:

| Variable | Required | Default | Description |
|---|---|---|---|
| `AICOMMANDER_TOKEN` | no | — | **Account API key** (or OAuth access token) for the optional accounts/alias features — saved machines, aliases, account access. Generate one for free at [aicommander.dev](https://aicommander.dev). A key stays active only while its account has opened the dashboard within the last 24h (default; opt-out per account), and OAuth credentials follow the same window. Note this bridge forwards whatever you set here as a **static bearer** and has no OAuth flow of its own, so a paused credential surfaces as `Error 401` (OAuth) or a "reactivate" notice (API key) with no automatic recovery — open the dashboard to restore access. Clients that speak OAuth themselves (Claude's connector, streamable-HTTP clients) instead re-prompt for sign-in on their own. |
| `AICOMMANDER_SERVER` | no | `https://aicommander.dev` | Base URL of the AI Commander relay. Defaults to the hosted service; only set this to point at a different endpoint. |

## Usage

### Codex CLI

Recent Codex supports **streamable HTTP** directly — no Node/npx bridge needed:

```bash
codex mcp add aicommander --url https://aicommander.dev/mcp
```

> Pass `--url` **before** the URL. Without it Codex treats the URL as a command to
> launch and fails with `MCP startup failed: No such file or directory (os error 2)`.

Prefer the local stdio bridge? Edit `~/.codex/config.toml`:

```toml
[mcp_servers.aicommander]
command = "npx"
args = ["-y", "@aicommander/mcp"]
env = { AICOMMANDER_SERVER = "https://aicommander.dev" }
```

> **Windows:** use `command = "npx.cmd"` (or `command = "cmd"`, `args = ["/c", "npx",
> "-y", "@aicommander/mcp"]`). Plain `npx` resolves to `npx.exe`, which doesn't exist,
> so the spawn fails with the same `os error 2`.

### Claude Desktop — `claude_desktop_config.json`

```json
{
  "mcpServers": {
    "aicommander": {
      "command": "npx",
      "args": ["-y", "@aicommander/mcp"],
      "env": {
        "AICOMMANDER_SERVER": "https://aicommander.dev"
      }
    }
  }
}
```

> `AICOMMANDER_TOKEN` (an account API key) is optional — add it to `env` only if
> you want the accounts/alias features.

Then just name a machine in chat — by session code or saved alias:

> *"Show disk usage on AIC-XYZ-1234"* · *"connect to wearfits-m3"* · *"is my-laptop online?"*

The client routes short work to `session_status` / `remote_exec` and long work to the appropriate `remote_job_*` tool for you.

Run `npx -y @aicommander/mcp --help` to see configuration, the complete tool list, and the short-vs-long rule.

## Publishing

**Re-publish this package whenever you change code that ships in it** — e.g. a new
tool, an edited tool description, or anything under `bin/`. The version in
`package.json` / `server.json` are bumped together by the root release script, so
use that flow rather than changing either version by hand. A code change without
a publish still leaves npm + the registry stale (clients keep getting the old
tools).

Steps:

1. Run the root release flow, which bumps `package.json`, both version fields in
   `server.json`, and the other distribution versions in lockstep.
2. Push the tag. **npm publishes itself** — the `publish-npm` job in
   `.github/workflows/release.yml` runs `npm publish --access public` from here
   over OIDC trusted publishing, so there is no token and no 2FA OTP to enter
   (see OPERATIONS.md, "npm trusted publishing (SR-20)"). `prepublishOnly` runs
   `npm run build`, i.e. `tsc --noEmit` followed by the esbuild bundle into
   `dist/`, so the tarball is built from the tagged source.
3. Publish to the official MCP Registry — **manual, and easy to forget**: it does
   not run in CI, because domain ownership is proven with an Ed25519 key that
   lives only on the maintainer's machine. From the repo root:

   ```sh
   pnpm publish:mcp-registry     # verify afterwards: pnpm check:mcp-registry
   ```

   That is `scripts/publish-mcp-registry.mjs`, which preflights the manifest
   (the name must be exactly `dev.aicommander/mcp` — registry entries are
   immutable — the two version fields must agree, and the registry caps
   `description` at 100 characters and rejects the publish outright above it),
   then runs exactly this underneath, in `packages/mcp`:

   ```sh
   mcp-publisher login http --domain=aicommander.dev \
     --private-key=$(cat ~/.config/aicommander-mcp-publish/privkey.hex)
   mcp-publisher publish
   ```

   Login proves ownership over HTTP via
   `https://aicommander.dev/.well-known/mcp-registry-auth`, so the Worker must be
   deployed first for the well-known endpoint to be live. Set
   `MCP_PUBLISHER_KEY_FILE` if the key is not at the default path. `mcp-publisher`
   takes the key only as a command-line argument, so for the seconds the login
   runs it is visible to anything that can read this machine's process table —
   another reason this step belongs on the maintainer's own laptop and nowhere
   shared.

`pnpm check:mcp-registry` exits 0 in sync, 1 behind, 2 unreachable (which is
*not* proof it is up to date), 3 when `server.json` itself is unpublishable.

## License

MIT — the full text ships in the published tarball as `LICENSE.txt`.

`dist/bin/mcp.js` is an esbuild bundle that inlines the sources of the
MIT-licensed workspace package `@aicommander/protocol`, so the tarball is also a
copy of that code and carries its notice in `THIRD-PARTY-NOTICES.txt`. The
runtime dependencies (`@modelcontextprotocol/sdk`, `zod`) are marked external and
are NOT bundled — npm installs them separately under their own terms.
