mcp-devvm
Provides tools for inspecting and manipulating Git repositories on a dev VM — read-only status checks (current branch, HEAD, dirty state) and branch checkout with precondition check-and-act, with mutating operations gated behind human approval.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-devvmswitch the api repo to the fix/login-timeout branch"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-devvm
A working implementation of the architecture in MCP_SERVER_DEVVM.md:
an MCP server that lets an AI agent do real work on a dev VM — run tests, switch
branches, restart services — while being treated as hostile at every step. The
agent never runs a command. It selects a typed operation from a fixed,
reviewed registry; a daemon (devd) on the VM re-validates and sandboxes it
from the same declaration a human approves; nothing executes without an
human decision that devd itself verifies: a signature from an approver
key that only the human can unlock.
This covers the doc's Phases 0–5 (§26.3) against a Docker container standing in for the dev VM, with namespace-mode (bubblewrap) sandboxing. Phase 6's production hardening — HSM-backed keys, real cloud IaC, external audit anchoring, paging — needs real infrastructure this sandbox doesn't have; see What's simulated below for the honest accounting.
Everything described as "working" here was actually run — the dev-VM container is built and driven for real, over a real SSH tunnel, through real bubblewrap sandboxes, with a real approval CLI in a separate process. 145 tests pass, including 12 integration tests against the live container.
See ARCHITECTURE.md for two diagrams — the request lifecycle end to end, and which file talks to which and when — and ROADMAP.md for what's done vs. outstanding against the doc's own build order.
Architecture
🤖 Agent → MCP Client → MCP Server (server/, this host)
│
├─ policy · proposals · approvals (SQLite)
├─ audit (hash-chained JSONL)
│
══ SSH, cert auth, no forwarding ══
local unix socket → one SSH session per connection
→ forced command pipes it to devd's unix socket
│
▼
devd (devd/, the "VM" container)
│
operation registry · own validation
· own policy · sandbox from manifest
│
▼
bubblewrap namespace · git/tail/tee
· run records · local auditTwo independent enforcement points (doc's B5): a compromised control plane
still can't exceed the operation set, because devd re-validates every
param against its own on-disk manifests, re-checks the per-request token's
operation scope, and — for anything that needs approval — verifies a human
approver's signature over the exact proposal before doing anything.
Related MCP server: MCP ToolHub
Quick start
uv venv .venv && uv pip install --python .venv/bin/python -e ".[dev]"
# Install a root-owned copy of the approval CLI that the agent can't edit
# (it asks you to confirm the git commit it installs from), and create
# /etc/mcp-devvm/approvers/, the root-owned directory of approver public
# keys that devd trusts.
sudo scripts/install_decide.sh
# Build + start the demo dev-VM container, mint the CA and token-signing
# keys, and pin the container's real sshd host key into config/targets.yaml.
.venv/bin/python scripts/setup_dev_vm.py
# Create your approver key. It's named after your OS user, one per user.
# You pick a passphrase; it's never stored, and you'll type it each time
# you approve something.
/opt/mcp-devvm-approve/bin/approver-keys init
# Enroll it, so devd trusts it. This needs root because the trusted key
# directory must not be writable by you (or an agent running as you). It
# asks for your passphrase to prove the key is yours.
sudo /opt/mcp-devvm-approve/bin/approver-keys enroll
# Run the MCP server (stdio transport) -- wire this into Claude Code / any
# MCP client the same way you would any other stdio server:
# command: <repo>/.venv/bin/python
# args: ["-m", "server.main"]
# cwd: <repo>
.venv/bin/python -m server.mainFrom the agent, request_operation("git.checkout", {"repo": "api", "branch": "..."}, "reason")
returns {"approval_id": ..., "status": "PENDING"} and executes nothing.
Approve or deny it from a separate terminal — this is deliberately not
an MCP tool (doc §7.2, B4). Approving asks for your approver passphrase and
signs the exact proposal and effect text you were shown:
/opt/mcp-devvm-approve/bin/decide list
/opt/mcp-devvm-approve/bin/decide show appr_xxxxxxxx
/opt/mcp-devvm-approve/bin/decide approve appr_xxxxxxxx(.venv/bin/python -m server.approvals.decide ... runs the same code from
the repo. That's fine for trying it out with an agent that has no shell,
but an agent that can edit the repo could change that copy to capture your
passphrase.)
For DESTRUCTIVE and PRIVILEGE operations, devd needs two approvals from
two different OS users, and neither can be the user who made the request.
The running MCP server's background dispatcher notices the approval (a
separate process writing to the same SQLite store) within ~2s and executes
it. check_run_status(approval_id) from the agent shows the outcome.
Using this from VS Code (Copilot Chat agent mode)
VS Code's built-in MCP support (via the GitHub Copilot Chat extension's
Agent mode) reads server definitions from a .vscode/mcp.json file in the
workspace. Create one at the repo root:
{
"servers": {
"mcp-devvm": {
"type": "stdio",
"command": "${workspaceFolder}/.venv/bin/python",
"args": ["-m", "server.main"],
"cwd": "${workspaceFolder}"
}
}
}On Windows, use "${workspaceFolder}\\.venv\\Scripts\\python.exe" instead.
cwd matters here specifically: server/main.py resolves config/ and
data/ as paths relative to the process's working directory, not the
script's location — without "cwd": "${workspaceFolder}", VS Code may
launch the server from some other directory and every tool call will fail
looking for a config/targets.yaml that isn't there.
Then, before using it:
Complete the Quick start above first (
.venvcreated,scripts/setup_dev_vm.pyhas built and started the container) — the server has nothing to talk to otherwise.Reload or open the Command Palette (
Cmd/Ctrl+Shift+P) → MCP: List Servers to confirm VS Code picked upmcp-devvm, or MCP: Add Server if you'd rather click through the setup instead of hand-writing the JSON above (choose "Command (stdio)", point it at the same command/args/cwd, and save it to the workspace).Open Copilot Chat, switch the mode dropdown at the bottom of the chat box from Ask/Edit to Agent.
Click the tools icon (🔧) in the chat input and confirm
mcp-devvm's tools are enabled —list_targets,list_operations,describe_target,run_operation,request_operation,check_run_status,get_run_output,list_my_pending_approvals.Chat normally — e.g. "check out feature/login-fix in the api repo." The agent calls
request_operationand stops there; nothing executes.
The approval step stays outside VS Code's agent loop, on purpose.
server/approvals/decide.py is deliberately not an MCP tool (doc §7.2,
B4 — the decision channel the agent cannot reach), so approving or denying
what the agent proposed happens in an ordinary VS Code integrated terminal
(Ctrl+`), same commands as the Quick start section:
/opt/mcp-devvm-approve/bin/decide list
/opt/mcp-devvm-approve/bin/decide approve appr_xxxxxxxxThe shipped operations
Four operations across three risk classes — enough to exercise the whole pipeline without the scope of NETWORK_EXEC/container-sandboxing or DESTRUCTIVE/four-eyes, which this build skips (see below):
operation | risk | approval | what it does |
| READ_ONLY | none | branch, HEAD, dirty state for a workspace repo |
| READ_ONLY | none | last N lines of the demo app log |
| MUTATING_LOCAL | required | switch branches locally, with precondition check-and-act |
| SERVICE_CONTROL | required + step-up | rewrites a state file (stand-in for |
Add a new one by dropping a <name>.yaml manifest + <name>.py
implementation into devd/operations/ (see git_checkout.py for the
shape) — devd/registry.py picks it up automatically.
What's simulated, and why
Being upfront about every substitution, so nothing here is mistaken for a production posture:
--privilegedon the dev-VM container. Bubblewrap needs unprivileged user-namespace creation, which this sandbox's own Docker doesn't grant to a nested container without it (tested: fails withpivot_root: Operation not permittedunder narrower capability sets). On a real, non-containerized dev VM kernel, bubblewrap works unprivileged out of the box — this flag is purely an artifact of running the "VM" as a container inside this environment's own container.Software SSH CA, not HSM-backed (
scripts/ca.py). Realssh-keygen -smechanics — short-lived certs, source-address pinning, principal attribution — just with the CA private key as a 0600 file instead of in a KMS. The doc's own fallback ladder for topology-A-without-CA-infra explicitly allows this.Approver keys are passphrase-encrypted files, not hardware keys.
data/approvers/private/<name>.keyis readable by your OS user, so the passphrase is what protects it. A hardware key or passkey would remove the file entirely; see Known limitations.devd's mutating-call handling runs synchronously, not backgrounded with polling (doc §13.5). Every shipped operation finishes in well under a second, soPOST /operations/{id}/runawaits completion and returns the terminal status directly, while still writing a durable run record before the effect starts withstatuswritten last — soGET /runs/{id}and idempotency-key replay behave identically to the fully backgrounded design even if the HTTP response never reaches the caller (exercised for real intests/integration/test_end_to_end.py::TestFaultInjection). A genuinely long-running operation would needasyncio.create_task+ polling; nothing else here would need to change to support that.SQLite control-plane store, single MCP-server process. Several users and targets are supported in config, but only one demo target is provisioned by
scripts/setup_dev_vm.py. The doc says SQLite (WAL,BEGIN IMMEDIATE) is adequate for the local topology; this just doesn't scale it to Postgres/multi-tenant.CLI approval (
server/approvals/decide.py), not a web UI. The doc treats the decision path as "the human path — NOT an MCP tool"; a terminal satisfies that boundary as well as a browser does, just with a plainer rendering of §19/§23's UI rules (never truncate, show writes/reads/network/services even when empty, typed confirmation for high-risk classes, a rate indicator).No NETWORK_EXEC / container-sandbox mode, no DESTRUCTIVE / four-eyes operations. Deliberately out of scope for this build (namespace-mode sandboxing only) — §14.3's ephemeral-container-per-run design for
npm install-style operations is unbuilt, and so is the four-eyes / typed-confirmation path for anything actually destructive, though the approval store'sapprovals_requiredmechanism and the CLI's high-risk confirmation prompt both already support it structurally.No Phase 6 hardening: no external audit-chain anchor, no real IaC provisioning or quarterly rebuild drill, no HSM/KMS, no paging/monitoring stack, no
cgroup/disk-quota resource limits beyond what bubblewrap's namespace isolation gives for free. The hash-chained JSONL audit sink (server/audit/sink.py) is the local piece such a pipeline would ship off-box; nothing here does the shipping.
What's real and independently verified
Not simplified, not mocked — each of these was driven end-to-end against the live container and is covered by a passing test:
Real bubblewrap sandboxing: verified undeclared reads fail (
No such file or directory), undeclared writes land in a throwaway filesystem (never the real host path), network is unreachable when undeclared.Real SSH: a genuine certificate-authenticated connection, a genuine per-request JWT devd independently verifies against a key it only holds the public half of. The tunnel account can't forward anything: TCP and stream-local forwarding are both refused, and any command it's asked to run only ever reaches devd's socket (
TestTunnelAccountScope).Real human-approval signatures: a DB row marked approved by hand (what an agent with a shell could do) is refused by devd with 403, and so is a real signature when the effect text shown to the human was altered (
TestApprovalSignatures).Real cross-plane enforcement (B5): a token minted for one operation is rejected (403) if replayed against a different operation's endpoint.
Real manifest-version binding: a proposal made against an older manifest version settles
STALE, not silently run against different code; editing the stored version without a matching hash settlesUNAUTHORIZED.Real precondition check-and-act:
git.checkoutre-verifiesHEADand worktree cleanliness inside the sandboxed operation, microseconds before the mutation; stale preconditions get409 precondition_drift.Real idempotency: replaying the same
Idempotency-Keynever re-executes, whether the original attempt settled or never started.Real fault injection: a simulated transport drop mid-call settles
UNKNOWN, neverSUCCEEDED/OP_FAILED; the reconciler (a real polling task, not a mock) resolves it via devd's own idempotency-keyed run lookup — read-only, never re-executing — and escalates toNEEDS_HUMANonce genuinely exhausted.Real hash-chained audit trail: tampering with any past record breaks
verify_chain().
Testing
.venv/bin/python -m pytest tests/unit -q # pure logic, no infra
.venv/bin/python -m pytest tests/adversarial -q # isolation/structural gates
.venv/bin/python scripts/setup_dev_vm.py --test-approvers
.venv/bin/python -m pytest tests/integration -q # against the live containerThe integration tests trust throwaway approver keys by writing them into
devd's key directory, so they need a VM set up with --test-approvers,
which mounts a directory your user can write. Don't use that VM with an
agent connected. Against a normal VM, the tests that approve something
are skipped.
Project layout
server/ control plane: auth, policy, proposals, approvals (CAS
store + state machine + CLI decide.py), execution
(executor/triage/reconciler/dispatcher), tunnel (the
ONLY module that opens SSH), audit, the MCP tool surface
devd/ data plane: FastAPI app, operation framework
(OperationContext/Workspace/Precondition), bubblewrap
sandbox builder+runner, operations/ (the perimeter)
common/ shared pure logic: param validation, canonical JCS-like
hashing, output sanitize/redact -- independently invoked
by both planes, never a trust dependency between them
vm/ the container standing in for the dev VM: Dockerfile,
sshd hardening, entrypoint, demo data seeding
scripts/ SSH CA, token-signing keypair, approver keys, dev-VM setup
config/ targets.yaml (server-side only), policy.yaml, roles.yaml
tests/ unit, adversarial (isolation/structural), integration
(against the real container)Known limitations to know about before extending this
The approval CLI is only as safe as the copy you run. Signed approvals stop an agent from approving on its own: writing the DB, or using the server's token key (both readable by your OS user), no longer gets anything that needs approval past devd. The repo copy of
decide.pycan be edited by an agent, so use the root-owned copy fromsudo scripts/install_decide.sh, invoked by its full path. That copy still runs as your OS user, inside a shell whose startup files the agent can edit, so a hardware key or a separate OS account for approving is stronger. Use a strong passphrase: the encrypted key file can be copied and attacked offline.Enrolling approvers needs root. devd only trusts public keys in the root-owned
/etc/mcp-devvm/approvers/, so an agent running as you can't add one of its own. Adding a person meanssudo approver-keys enroll; revoking one meanssudo rm /etc/mcp-devvm/approvers/<user>.pub. Keys from before this change indata/approvers/public/are no longer trusted: enroll them again.Multiple users and targets need some setup by hand. Add users to
config/roles.yamland give each approver their own key; add targets toconfig/targets.yamlandconfig/policy.yaml. The agent passestarget_idwhen there's more than one.scripts/setup_dev_vm.pyonly provisions the one demo container.No egress allowlist / IMDS blocking / disk quota enforcement on the VM side beyond what
vm/harden.shdoes (link-local block, permission checks) — real §17 blast-radius hardening needs a real VM and IaC.
License
MIT — see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Operate Linux, macOS and Windows from your LLM. Every action runs through an auditable allowlist.
- emisarOAuthdev.emisar
Let AI operate servers without SSH. Choose actions, approve risky changes, and audit every step.
Persistent cloud workspaces for AI agents: run commands, edit files, use git and a browser.
Linux VMs for AI agents: create VMs, run commands, edit files and open preview URLs.
Related MCP Servers
- FlicenseAqualityCmaintenanceEnables AI coding agents to run Kubernetes inspection and Terraform plan/apply operations inside ephemeral gVisor-sandboxed jobs with short-lived, narrowly-scoped credentials, while routing destructive changes through a human approval gate.3-
- FlicenseAqualityAmaintenanceEnables coding agents to perform workspace-confined file operations, read-only Git inspection, and structured shell commands, while requiring out-of-band human approval for mutations and external executions and maintaining an audit trail.143-
- AlicenseNot gradedqualityAmaintenanceEnables AI clients to securely inspect and edit approved code, use semantic code intelligence, run builds/tests, supervise bounded processes, inspect Git, and execute owner-approved SSH commands on engineering workstations.2Apache 2.0
- AlicenseNot gradedqualityAmaintenanceEnables AI assistants to securely inspect, modify, and manage local projects with trusted filesystem access, safe Git operations, controlled task execution, and developer runtime process management over stdio or HTTP.4Apache 2.0