agent-vm-mcp
# agent-vm-mcp
`agent-vm-mcp` is an MCP control plane for a dedicated Linux agent VM. It gives an MCP client shell-equivalent control of that VM, adds durable workspace and coding-agent orchestration, and can re-export tools from optional upstream MCP servers.
> [!WARNING]
> Access to this server is intentionally equivalent to shell access to the VM. The VM is the trust boundary. Do not expose this MCP endpoint to clients you would not trust with the VM, its credentials, mounted data, browser sessions, network reachability, Docker access, or SSH access.
## What it provides
The native MCP surface includes:
- finite shell execution with cancellation, bounded previews, and artifact spillover;
- bounded filesystem reads, deterministic directory listing, and strict unified-diff patching;
- managed Git repository stores and isolated Git worktrees;
- persistent interactive process sessions;
- managed bounded coding-agent execution for Codex and Antigravity CLI, with no raw harness TUI fallback on the ChatGPT host;
- CLI capability discovery and a read-only system maintenance audit;
- generic-host projected-skill access; the ChatGPT host intentionally exposes no dot-agents skill surface;
- opaque MCP artifact resources and host-native file/image presentation;
- upstream MCP bridging over stdio or Streamable HTTP, with tool filtering, prefixing, renaming, adapters, and call policies.
A clean checkout starts with **no upstream bridges enabled**. Playwright, LSP, browser takeover, tunnel integration, and coding harnesses are optional deployment features.
## Trust model
The supported model is deliberately simple:
> Give the agent a dedicated VM. The operator decides what that VM can reach; inside the VM, assume the agent can reach everything available to its Linux user.
`agent-vm-mcp` is **not** a sandbox, privilege boundary, multi-tenant isolation layer, or authorization broker between tools running inside the same VM. Path checks, ownership markers, bridge policies, workspace identity checks, and similar guards exist for orchestration correctness and accidental-cross-control prevention, not to protect secrets from a malicious process already running as the trusted VM user.
For the full security model, see [`SECURITY.md`](SECURITY.md).
## Quick start
Requirements:
- Node.js `24.20.0` or a compatible Node 24 release accepted by `package.json`;
- pnpm `11.25.0`.
Install and start the native core:
```bash
pnpm install --frozen-lockfile
pnpm start
```
The server communicates over stdio. With no machine-local configuration, the built-in bridge configuration is empty, so missing Playwright/LSP services do not prevent startup.
## Configuration
Machine-specific configuration belongs under:
```text
$XDG_CONFIG_HOME/agent-vm-mcp/
# or, when XDG_CONFIG_HOME is unset:
~/.config/agent-vm-mcp/
```
For file-based configuration, resolution is:
1. an explicit path supplied by the caller/runtime;
2. the corresponding environment-variable override;
3. the XDG machine-local file, when it exists;
4. the repository's built-in default under `config/`.
The main configuration files are:
| Purpose | XDG filename | Environment override |
| --- | --- | --- |
| MCP bridges | `bridges.json` | `MCP_BRIDGES_CONFIG` |
| CLI capabilities | `capabilities.json` | `AGENT_MCP_CAPABILITIES_CONFIG` |
| System audit | `system-audit.json` | `AGENT_MCP_SYSTEM_AUDIT_CONFIG` |
| Shared Playwright stdio proxy | `playwright-shared-proxy.json` | `PLAYWRIGHT_SHARED_PROXY_CONFIG` |
`AGENT_MCP_HOST` selects connection/deployment-specific host behavior. Supported values are `generic` (default) and `chatgpt`. Host-specific extensions are kept out of ordinary tool inputs; for example, ChatGPT file-input metadata is attached to `import_file` only when `AGENT_MCP_HOST=chatgpt`. The ChatGPT profile also enables the structured interaction adapter, disables projected-skill protocol/tools, and forbids interactive terminal session launchers through `exec`/`process_start`.
See [`config/README.md`](config/README.md) for bridge schema, fail-soft/fail-hard behavior, adapters, policies, capability discovery, system-audit configuration, and deployment examples.
## Bridge behavior
Every bridge is an optional integration. `enabled: true` means "attempt to connect and expose this integration at startup"; it does **not** make the upstream service a prerequisite for the native core.
The contract is:
> Invalid configuration fails startup. Unavailable integrations do not.
Examples of unavailable integrations include a missing stdio executable, connection refusal, an offline HTTP service, or an upstream handshake/tool-list failure. Those bridges are reported as `state: "unavailable"` by `mcp_bridge_status`, while other bridges and the native core continue.
Invalid JSON, unsupported config versions, duplicate bridge IDs, invalid adapter/policy configuration, unsupported transport definitions, and deterministic exported-tool collisions are startup errors. Partial bridge initialization is rolled back before the error escapes.
## MCP Skills (SEP-2640)
The canonical MCP-facing skill surface follows the [Final SEP-2640: Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640): the server declares the `io.modelcontextprotocol/skills` extension and exposes `skills/list`, `skills/get`, standard `resources/read`, and `resources/directory/read`. Skill identity is its URI, using `skill://<skill-name>` for a skill root and `skill://<skill-name>/<path>` for files and directories. Listings include complete parsed `SKILL.md` frontmatter and a digest/size manifest for every regular file; reads are lazy and bounded by the SEP limits.
The dot-agents projection is an internal publication snapshot, not a second protocol model. It is a version 1 directory containing `catalog.json` and `skills/<name>/...`; the catalog `source` must be `dot-agents`, and its tree hash is verified before content is served. Filesystem paths are never exposed.
The default projection root is:
```text
${XDG_DATA_HOME:-$HOME/.local/share}/dot-agents/skill-projection/v1/
```
Set `AGENT_MCP_SKILL_PROJECTION_ROOT` to use another projection root. On the generic host profile, SEP-2640 and the native `skill_list`/`skill_read` compatibility tools consume the validated projection. The ChatGPT host profile intentionally registers neither surface, so ChatGPT does not consume dot-agents skills directly. Projection content remains instructions/data for hosts that explicitly opt into it.
## Structured user input
The interaction model is host-neutral. It currently supports single-select, multi-select, free-text, and legacy boolean questions, with stable question/option IDs and optional recommendations. New binary choices should use `single_select` as well, so they can express an uncertain/custom state without introducing a second overlapping choice primitive; `boolean` remains accepted for compatibility with existing callers. Every `single_select` and `multi_select` question is normalized to include exactly one reserved option with ID `other`, label `Other`, and `allowCustomInput: true`; selecting it requires an inline free-text value. Callers should not add this option. An existing option with ID `other` or a label equal to `Other` after trimming surrounding whitespace and ignoring case is normalized in place without creating a duplicate. Other arbitrary options can still set `allowCustomInput: true` (and optionally `customInputPlaceholder`) to require their own inline detail only when selected. For multi-select questions, a supplied `maxSelections` continues to count selected choices including automatic `Other`; omitting it preserves the existing no-explicit-cap behavior. Host-specific rendering is implemented by adapters rather than encoded in the core request schema.
When `AGENT_MCP_HOST=chatgpt`, the server exposes `request_user_input` and binds new calls to the content-addressed v2 MCP Apps resource `ui://agent-vm/request-user-input/v2-<sha256-prefix>.html`. Its URI includes a digest of the bundled UI HTML, so changing the widget gives the tool a new resource identity. The v1 resource URI and its original HTML remain registered for cards already present in conversations; do not edit `chatgpt-app.html` in place. The tool result also contains a plain-text fallback, so the request remains understandable if the UI cannot render.
In v2, Submit first sends a normal `role: "user"` text message through `ui/message`. That message includes the title, every question prompt, selected labels, stable option IDs, and custom values, so the model can understand the answers from the conversation alone. Only after the host acknowledges the message does the view lock that rendered interaction and ask the server to save its authoritative submitted state. A failed or unacknowledged `ui/message` leaves the server state pending and the form retryable. If the message succeeds but the later state save fails, the current rendered view stays locked to avoid sending it again automatically. Since the host controls delivery and acknowledgement for `ui/message`, an acknowledgement lost after the host has accepted a message remains ambiguous; a manual retry in that case can create a duplicate conversation message.
The v2 ChatGPT form renders every user-authored free-text field as a resizable `textarea` with a default height of two rows, including `kind: "text"` questions and option-specific custom-input fields. The existing `multiline` request field remains part of the host-neutral schema for compatibility, but it no longer changes the ChatGPT v2 control type.
After an acknowledged v2 submission, the App replaces editable controls with a visible submitted-response summary generated from the same text formatter used for the `ui/message` user turn. Persisted submitted interactions hydrate back into the same summary; if only the secondary state save fails, the summary remains visible with the save warning and stays latched against resend.
The historical v1 view and its App-only `request_user_input_state` / `request_user_input_submit` tools retain their original read and submit behavior for old cards. V2 uses separate App-only state and submit tools so each version remains associated with its own resource. The server durably stores each normalized request and validated structured answers keyed by immutable `interactionId`; a later rendering can hydrate persisted answers and show the disabled `Submitted` state. Re-submitting identical answers is idempotent, while a different answer set is rejected. The UI's sandbox-local `localStorage` is only a draft cache and is not the submitted-state source of truth. The state file is written atomically under the configured state root with private file permissions; authoritative hydration and secondary persistence require MCP Apps server-tool capability.
The generic host does not expose `request_user_input` yet. Future hosts can register their own adapter against the same interaction model without adding host-specific fields to the public schema.
## Native file and execution tools
`read_file` returns bounded UTF-8 content plus a whole-file SHA-256 `revision`. When the resolved target is inside the supplied `cwd`, it also returns a reusable `{ cwd, path }` locator so a later `edit_files` call does not need to reconstruct the relative path.
`edit_files` is the model-oriented filesystem mutation surface. It supports exact ordered edits to existing UTF-8 files, non-overwriting file creation, and revision-guarded file deletion. The whole batch is prevalidated before mutation; preflight failures are zero-write. On Linux, mutation paths are anchored to opened `O_DIRECTORY|O_NOFOLLOW` parent handles, so replacing a validated parent path with a symlink cannot redirect commit or rollback into another directory tree. Once commit begins, cancellation does not interrupt it. Ordinary commit failures trigger rollback, while rollback refuses to overwrite a target that was independently changed and preserves recovery evidence instead. Multi-file edits are not claimed to be process-crash-atomic.
`exec` accepts exactly one of a shell `command` or literal `argv`. Prefer `argv` when shell syntax is unnecessary so quoting and argument boundaries stay explicit; use `command` for pipes, redirects, and compound shell expressions. Raw coding-harness execution remains blocked in both modes.
Core mutating tools return machine-readable error envelopes with stable `error.code`, human-readable `message`, and tool-specific `details` when available. Schema-validation errors remain protocol-level input errors.
## Artifacts and host presentation
Artifacts use opaque process-local URIs such as:
```text
artifact://agent-vm/<id>
```
Artifact registration and user presentation are separate operations. Tool-generated artifacts such as oversized `exec` stdout/stderr are model/internal references by default: the tool result carries an opaque URI but does **not** attach a user-facing `resource_link`. The model can inspect text artifacts in bounded chunks with `read_artifact`; supported image artifacts can also be returned as standard MCP image content.
`present_file` explicitly registers and presents a VM file to the user. `present_artifact` explicitly presents an already-registered artifact. Those presentation tools return standard MCP `resource_link` content; small text files are additionally embedded as MCP resource content. Bridge adapters register output artifacts without automatically presenting file links, while image-producing adapters may attach standard MCP `image` content for same-turn model inspection.
Caller-owned files registered by `present_file` remain **live references**, not immutable snapshots. Their metadata describes the file when it was registered; a caller that mutates the underlying file later is changing what the live reference points to. Temporary spill files created by `exec` are artifact-store-owned and cleaned up on expiry or graceful shutdown.
Defaults:
- artifact TTL: 24 hours;
- maximum artifact read size: 50 MiB;
- `import_file` maximum download size: 256 MiB.
Override them with `AGENT_ARTIFACT_TTL_MS`, `AGENT_ARTIFACT_MAX_BYTES`, and `AGENT_FILE_IMPORT_MAX_BYTES`.
Both `read_file` and artifact resource reads enforce their source-size limit during I/O through one opened file descriptor rather than relying on a path-level `stat` followed by an unbounded `readFile`.
## Server and tool-catalog identity
`server_info` reports the identity of the **running MCP process**, including the package version, Git revision captured when that process started, dirty-tree state, host profile, PID, and start time. The revision is intentionally captured at server startup rather than read on every call, so a checkout that has been fast-forwarded before its old process is restarted cannot masquerade as the new deployment.
The same tool reports a deterministic SHA-256 identity for the active MCP action catalog. The hash covers the public tool definitions that matter to a host snapshot—name, title/description, input/output schemas, annotations, icons, execution metadata, and `_meta`—including connected bridge tools. Handler implementation changes alone do not change the catalog hash. `server_info` is excluded from its own hash to avoid self-reference.
The `server_info` tool description embeds the catalog marker that ChatGPT saw when it fetched that tool definition. Compare that embedded marker with `catalog.marker` returned by a live `server_info` call. If they differ, the running server and the host's frozen action snapshot disagree. Treat that mismatch as a terminal workflow boundary: do not call or rediscover any other tools from that server in the same workflow; return control to the user and refresh the app actions before continuing. For self-deployment, finish cleanup and ancillary work before restarting the MCP/tunnel process, then use one post-restart `server_info` call as the deployment acceptance check. `catalog.toolNames` and the reported counts provide an additional sanity check.
## Workspaces and Git
`workspace_create` manages a shared bare repository store plus isolated Git worktrees. A workspace has its own working tree, index, and `HEAD`; objects, refs, tags, remotes, repository-level config, and stash remain shared within that repository store. `createBranch` atomically compare-and-creates the local branch ref and uses expected-OID cleanup, so a failed request does not delete a branch that another actor created or subsequently moved. It also fails closed when the requested name is already present locally or on the refreshed `origin` tracking refs. `idempotencyKey` persists a pending workspace intent before worktree creation and uses a cross-process owner lock based on Linux PID/start-time identity, so a retry after a lost response or MCP restart reuses or recovers the same workspace identity instead of intentionally creating a second one; reusing the key for different inputs is rejected.
Defaults:
- repository store: `~/.local/share/agent-vm/repositories`;
- worktrees: `~/workspaces`.
Override them with `AGENT_REPOSITORY_ROOT` and `AGENT_WORKSPACE_ROOT`.
Workspace lifecycle does not install dependencies, trust repository toolchains, commit/stash changes, start processes, or manage containers. It creates a task branch only when `createBranch` is explicitly supplied. Repository authentication stays with normal Git credential mechanisms. HTTP(S) clone URLs with embedded userinfo, query parameters, or fragments are rejected to reduce accidental credential persistence.
## Processes and coding agents
`process_*` tools manage generic processes owned by the running MCP server. `process_read` returns an opaque cursor; pass it back on the next read instead of manually carrying stdout/stderr offsets. Explicit offsets remain available for compatibility and cannot be mixed with a cursor. On graceful shutdown, managed process groups receive `SIGTERM` and are escalated to `SIGKILL` after a bounded grace period. These process sessions are not persisted across server restarts.
Normal coding-agent work uses **durable background jobs**. `agent_start` writes a job to a separate VM-owned `agent-jobd` service and returns an immutable `runId` once SQLite has accepted it. Jobs queue when the configured worker limit is reached; queued results expose position/capacity diagnostics instead of requiring callers to infer why they have not started. No ChatGPT/MCP connection must remain open. `agent_poll` reads incrementally with at most a 15-second wait and returns one opaque cursor covering stdout, stderr, structured events, and invalid lines; explicit per-stream offsets remain available for compatibility. `agent_result` returns a bounded output preview **plus absolute paths to complete stdout/stderr and structured JSONL logs**. `agent_list` rediscovers jobs across ChatGPT turns, MCP restarts, and background-manager restarts. `agent_cancel` can cancel queued or running jobs.
- **Defaults:** two concurrent jobs per VM, two-hour per-job timeout, eight-hour maximum. A job timeout is *not* the lifetime of an MCP request. Worker health comes from Linux PID/start-time identity and database state, never from guessing that a quiet model is stalled.
- **Safe retry:** send an `idempotencyKey` unique to a logical job. A repeated identical request returns the same `runId`; reusing the key with different inputs is rejected. Without the key, a request whose response was lost may be duplicated by retrying.
- **Restart semantics:** the job-manager and MCP processes are separate from detached worker process groups. Manager restart reattaches to still-running workers without restarting them; reboot or lost workers are marked `interrupted` instead of being replayed. SQLite and log files remain available. A failed/interrupted job must be examined and explicitly reissued.
- **Persistence:** jobs live in the configured `AGENT_JOB_STATE_DIR` (default `~/.local/state/agent-vm-mcp/jobs`); its SQLite database and per-run output directories are private to the VM account. Back up this directory if the results matter.
- **Scope:** V1 is individual Codex/agy jobs only. Orchestrating multiple independent reviewers and final synthesis stays with ChatGPT for now; job groups, dependency graphs and completion notifications are reserved for V2.
`agent_run` remains the unchanged **short, synchronous compatibility helper** (15-second default, 30-second maximum) and still uses the preexisting in-MCP bounded runner. It is not a background job and is cancelled if the MCP process terminates. The Codex model for existing MCP-managed invocations is fixed to `gpt-5.6-luna` / `max`, independently of dot-agents user defaults; the background service inherits the same configured deployment policy. Antigravity uses non-interactive print mode and structured output.
`work_status` now includes persistent background jobs plus legacy short-run metadata and managed generic processes. The repository/filesystem evidence is read separately. A successful process exit is not evidence that a review is semantically correct; inspect the saved findings and Git state before claiming completion.
Raw coding-harness TUI execution is not a supported fallback. On the ChatGPT host, `exec` and `process_start` reject interactive terminal session launchers (`tmux`, `screen`, and `script`) as well as raw coding-harness execution. Codex and Antigravity work must flow through the managed `agent_*` tools.
## Read-only maintenance audit
`system_audit` discovers installed tooling and reports update/health information without installing, fetching Git refs, restarting services, or otherwise mutating the VM.
For repositories, local ancestry is evaluated against the local tracking ref and reported as `in_sync`, `ahead`, `behind`, or `diverged` with counts. A live `git ls-remote` lookup, when requested, is reported separately as an observation of the current remote head; unequal hashes are not automatically labeled "behind".
Project dependency checks accept valid `pnpm outdated --format json` output even when pnpm exits non-zero, while a failed invocation with no usable JSON is surfaced as an audit error rather than an empty successful result.
## Optional Ubuntu deployment
The repository includes opinionated provisioning scripts for a dedicated Ubuntu VM. They are deployment helpers, not prerequisites for the portable native core.
The default deployment user is `agent`; set `AGENT_VM_USER` to use another existing user.
```bash
sudo ./scripts/provision-docker.sh
sudo ./scripts/provision-mise.sh
sudo ./scripts/provision-lsp.sh
sudo ./scripts/provision-browser-takeover.sh
sudo ./scripts/provision-agent-jobs.sh # after deploying sources to /opt/agent-vm-mcp
```
The scripts currently provision or configure:
- Docker/Compose/Buildx;
- mise-managed Node and pnpm;
- a controlled read-only LSP integration under `/opt/language-server-mcp`;
- a pinned Playwright MCP + Chromium deployment under `/opt/playwright-mcp`, plus optional persistent browser/noVNC infrastructure and launchers.
`/opt/agent-vm-mcp` is the canonical deployment path for the optional systemd/browser takeover deployment. `scripts/provision-browser-takeover.sh` intentionally fails unless it is run from that checkout, because the installed shared-browser proxy launcher executes control-plane source from that stable path.
Tunnel software is **not** part of the core lifecycle. Optional systemd examples may coordinate tunnel and browser services, but the MCP server and provisioners do not require an `agent-tunnel.service`.
For durable coding-agent background work and the independent systemd service, see [`docs/agent-background-jobs.md`](docs/agent-background-jobs.md).
For a complete illustrated setup, systemd, verification, and troubleshooting walkthrough, see [`docs/openai-secure-mcp-tunnel.md`](docs/openai-secure-mcp-tunnel.md).
A machine-specific Playwright/LSP bridge configuration is provided as `config/examples/bridges.playwright-lsp.json`; copy/adapt it into the XDG configuration directory rather than turning those integrations into source defaults.
### Browser takeover
The optional browser takeover setup uses a persistent Xvfb display plus loopback-only x11vnc/noVNC and a loopback-only shared Playwright MCP service. Tailscale, when provisioned, is intended only as stable reachability for ordinary OpenSSH; the VNC/noVNC listeners remain bound to loopback and can be reached through an SSH local forward.
Automation and human input to the shared browser should be treated as mutually exclusive at the orchestration level. There is no browser-takeover lease API in this version.
## Validation
Portable/core validation, suitable for GitHub-hosted Ubuntu CI:
```bash
pnpm test
```
This runs with no required external Playwright/LSP bridge services and verifies that the clean native core starts successfully.
Trusted Agent VM acceptance, including the configured live Playwright/LSP integrations:
```bash
pnpm smoke
```
Production dependency audit:
```bash
pnpm audit --prod
```
## License
ISC. See [`LICENSE`](LICENSE).
TDQS
Scored across 27 tools
Tool purposes are largely distinct: exec vs process_start vs agent_start have explicit boundary guidance, and agent_* lifecycle tools map to unique operations. Minor overlap remains between capabilities and agent_capabilities, and agent_get/agent_read are somewhat close, but descriptions clarify intent.
A clear noun_verb/prefix pattern dominates (workspace_*, agent_*, process_*), but there are deviations: exec, read_file, import_file, present_file, apply_patch, and command_info use a different verb-first style, and the bare capabilities tool mixes conventions.
27 tools is heavy for a VM execution server, exceeding the 25-tool threshold. The large agent_* cluster plus separate process_* lifecycle families adds surface area and cognitive load, though each tool does earn some place.
Strong lifecycle coverage: file read/import/present, workspace CRUD, process start/read/write/kill/list, and full agent start/get/read/prompt/suspend/resume/stop. Minor gaps exist, such as no file write/delete counterpart to read_file and no explicit directory-creation or move operation.