coder: let the omp harness drive workspaces over Coders MCP server

Coder ships `coder exp mcp server` and omp is an MCP client, so the two compose
with no custom code. The agent stays on the host where its credentials already
live and reaches into the workspace, rather than shipping secrets into a
container.

- scripts/coder-mcp.sh wraps the server, passing a dedicated `omp-mcp` token via
  `docker exec --env-file` (mode 600 temp file, shredded by an EXIT trap) so the
  token never appears in argv or ps
- allow-list exposes 26 of 32 tools; template mutation is excluded because
  pushing a template is the one real path from a Coder principal to the host
  docker socket
- scripts/coder-token-rotate.sh handles the 168h token lifetime cap: mint,
  verify against the API, then revoke the predecessors
- .omp/mcp.json registers it project-scoped so 26 tools do not load into every
  unrelated omp session

The docker exec must stay in the foreground: backgrounding it makes bash
redirect stdin from /dev/null with job control disabled, and the MCP transport
dies on EOF with "MCP tool load failed ... Transport closed".

Verified: fresh omp session in this directory answered from the tools -
coder_get_authenticated_user returned loca, workspace ws1 running.
This commit is contained in:
Antigravity
2026-08-20 17:46:51 +02:00
parent 0e54b86dac
commit 3478e7b913
8 changed files with 185 additions and 2 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.env
.admin-credentials
.mcp-token
q5vault/.obsidian/
# terraform working dirs; the committed .terraform.lock.hcl is intentional

10
.omp/mcp.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"coder": {
"command": "/home/loca/dev/coder/scripts/coder-mcp.sh",
"args": [],
"disabled": false
}
}
}

View File

@@ -15,7 +15,7 @@ Every command on these pages runs from `/home/loca/dev/coder` and every docker i
| --- | --- |
| Stack is down, needs starting, or a new image tag is out | [[operations/install-upgrade]] |
| Someone asks why this host is not behind Authelia, or how the Owner account was bootstrapped | [[operations/auth-model]] |
| A template needs writing or pushing, or a workspace app must be reached | [[operations/workspaces]] |
| A template needs writing or pushing, a workspace app must be reached, or the omp harness should drive workspaces over MCP | [[operations/workspaces]] |
| Proving the deployment is healthy after any change | [[operations/verification]] |
| Looking up a path, container, volume, port or env var | [[reference/config-paths]] |
| Explaining a missing feature | [[reference/limits]] |

View File

@@ -164,6 +164,22 @@ sudo -n gunzip -t $D/coder-db.sql.gz && sudo -n ls -l $D
- artifacts are root-owned; retention keeps 14 dated directories
- `.env`, `.admin-credentials` and `.ai-credentials` are deliberately excluded — they are keys, not data; verify with `sudo -n tar tzf $D/stack-configs.tgz | grep -c credentials` returning `0`
## 1️⃣5️⃣ omp harness reaches Coder over MCP
```bash
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
| /home/loca/dev/coder/scripts/coder-mcp.sh | head -1
ls /tmp/tmp.* 2>/dev/null | wc -l
cd /home/loca/dev/coder && omp -p 'Call coder_get_authenticated_user and coder_list_workspaces. Report the username and each workspace name plus status.'
```
- expect the first line to contain `"serverInfo":{"name":"Coder Agent","version":"v2.35.3+..."}`
- expect the temp-file count to be unchanged, proving the wrapper's EXIT trap shredded the mode-600 env file holding the token
- expect the harness to answer from tools, not guesses: username `loca`, workspace `ws1` `running`
- if the harness reports no tools, check `~/.omp/logs/omp.<date>.<pid>.log` for `MCP tool load failed ... Transport closed`, which means the wrapper backgrounded the server and its stdin was redirected from `/dev/null`
- token expiry is the most likely 401 cause: the `omp-mcp` token lasts 168h maximum. Rotate with `scripts/coder-token-rotate.sh` and confirm with `sudo -n docker exec coder /opt/coder tokens list`
- 26 of 32 tools are exposed by design; template mutation is excluded. Confirm with a `tools/list` request and check that no `coder_create_template*`, `coder_update_template_active_version`, `coder_delete_template`, `coder_delete_task` or `coder_upload_tar_file` appears
## 🧹 Pre-publish wiki lint gate
Run this before every `build.sh` invocation, so before item 7 above. It is a gate, not a smoke test: it exits non-zero and publishes nothing.

View File

@@ -132,6 +132,41 @@ The risk, stated plainly: `/var/run/docker.sock` is bind-mounted into the `coder
- `mem_limit` bounds both containers, so a runaway build cannot swallow the host; see [[reference/limits]]
- treat Template Admin as equivalent to host root and keep it to the single operator account
## 🤖 Driving workspaces from the omp harness (MCP)
Coder ships its own MCP server, and the `omp` harness is an MCP client, so the two compose natively. This is the deliberate direction of the integration: the agent runs on the host where its credentials already live and reaches into the workspace, rather than installing an agent inside the container and shipping secrets to it.
Wiring:
- server: `coder exp mcp server`, run inside the `coder` container, speaking MCP over stdio. Confirmed `serverInfo {name: "Coder Agent", version: "v2.35.3+65e2bfb"}`
- wrapper: `scripts/coder-mcp.sh`, which supplies credentials and the tool allow-list
- registration: `/home/loca/dev/coder/.omp/mcp.json`, project-scoped on purpose. The server exposes 26 tools; loading those into every unrelated omp session would waste context, so it only mounts when the working directory is this project
```json
{
"mcpServers": {
"coder": { "command": "/home/loca/dev/coder/scripts/coder-mcp.sh", "args": [], "disabled": false }
}
}
```
Credentials:
- a dedicated Coder token named `omp-mcp`, not the operator session token, so it can be revoked alone. `.mcp-token`, mode 600, gitignored
- this deployment caps token lifetime at 168h (7d); `--lifetime 30d` is rejected outright. Rotate weekly with `scripts/coder-token-rotate.sh`, which mints the replacement, verifies it with a real API call, and only then revokes the previous ones
- the token is handed to the container through `docker exec --env-file` with a mode-600 temp file that an EXIT trap shreds. It is never an argument, so it never appears in `ps` or in an agent transcript
Tool allow-list, and why:
- 26 of 32 tools are exposed via `--allowed-tools`
- excluded: `coder_create_template`, `coder_create_template_version`, `coder_update_template_active_version`, `coder_delete_template`, `coder_delete_task`, `coder_upload_tar_file`
- rationale: pushing a template is the one real path from a Coder principal to the host docker socket, as described in the section above. Template mutation stays a human action; the agent gets read-only template inspection plus full workspace access. Verified: a `tools/list` through the wrapper returns 26 names and none of the excluded ones
- kept: `coder_workspace_bash`, `coder_workspace_read_file` / `write_file` / `edit_file` / `edit_files` / `ls`, `coder_workspace_list_apps`, `coder_workspace_port_forward`, agent and build logs, workspace create plus `coder_create_workspace_build` for start/stop/delete, the task tools, and `search` / `fetch`
Wrapper trap worth knowing: the `docker exec` must run in the foreground. Backgrounding it with `&` plus `wait` looks harmless but breaks the server, because with job control disabled bash redirects an async command's stdin from `/dev/null`; the MCP stdio transport then sees EOF immediately and the client logs `MCP tool load failed ... Transport closed` with no tools registered.
Verified end to end 2026-08-20: a fresh `omp` session started in `/home/loca/dev/coder` mounted the tools and answered from them — `coder_get_authenticated_user` returned `loca`, `coder_list_workspaces` plus `coder_get_workspace` returned `ws1` `running`.
## 📚 Sources
- https://github.com/coder/coder/blob/main/examples/templates/docker/main.tf

View File

@@ -14,7 +14,12 @@ reviewed: 2026-08-19
| `/home/loca/dev/coder/` | stack root; compose project name `coder` derives from this directory name | `loca`; run every compose command from here |
| `/home/loca/dev/coder/docker-compose.yml` | the whole deployment definition: images, pins, ports, env, volumes, memory ceilings | `loca`; edit here, never with `docker run` flags |
| `/home/loca/dev/coder/.env` | supplies `CODER_PG_PASSWORD` to compose interpolation | `loca`, mode 600, git-ignored; contents are never read into or quoted by any document |
| `/home/loca/dev/coder/.gitignore` | ignores `.env`, `q5vault/.obsidian/`, and terraform working state under `templates/` | `loca`; keeps the secret, editor state and tfstate out of version control while the provider lockfile stays committed |
| `/home/loca/dev/coder/.mcp-token` | dedicated Coder token named `omp-mcp`, consumed by `scripts/coder-mcp.sh` so the omp harness can call Coder's MCP tools | `loca`, mode 600, git-ignored; 168h maximum lifetime, rotate with `scripts/coder-token-rotate.sh` |
| `/home/loca/dev/coder/.omp/mcp.json` | project-scoped MCP registration; mounts the `coder` server only when omp runs in this directory | `loca`; holds no secret, the token is read by the wrapper |
| `/home/loca/dev/coder/scripts/coder-mcp.sh` | wraps `coder exp mcp server` with `docker exec --env-file` credential passing and a 26-tool allow-list that excludes template mutation | `loca`, mode 755; must run the docker exec in the foreground or the MCP transport dies on EOF |
| `/home/loca/dev/coder/scripts/coder-token-rotate.sh` | mints a replacement `omp-mcp` token, verifies it against the API, then revokes the previous ones | `loca`, mode 755; run weekly |
| `/home/loca/dev/coder/scripts/backup-stacks.sh` | nightly backup driver for both stacks, invoked by `stacks-backup.service` | `loca`, mode 755; runs as root from the timer, so no `sudo` inside |
| `/home/loca/dev/coder/.gitignore` | ignores `.env`, `.admin-credentials`, `.mcp-token`, `q5vault/.obsidian/`, and terraform working state under `templates/` | `loca`; keeps secrets, editor state and tfstate out of version control while the provider lockfile stays committed |
| `/home/loca/dev/coder/config/runtime-version.txt` | plain-text pin, currently `v2.35.3`; the human-readable twin of the image tag | `loca`; changes in the same commit as the tag |
| `/home/loca/dev/coder/deploy/code.nginx.conf` | source of truth for the four nginx server blocks | `loca`; the installed site file is a copy, so edit here first |
| `/home/loca/dev/coder/templates/docker/main.tf` | pre-validated docker workspace template; one `coder_app` at `subdomain = false`, persistent `/home/coder` volume, no docker socket in the workspace | `loca`; pushed by the operator with `coder templates push` |

66
scripts/coder-mcp.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Coder MCP server, wrapped for the omp harness.
#
# Speaks MCP over stdio, so it is spawned as a child process by an MCP client
# (omp registers it in .omp/mcp.json). It gives the agent Coder's own tool set:
# workspace lifecycle, in-workspace bash/file operations, task management and
# read-only template inspection.
#
# Credential handling: the token is written to a mode-600 temp file and handed
# to `docker exec --env-file`, never passed as an argument. That keeps it out of
# argv, out of `ps`, and out of any agent transcript. The file is removed when
# this script exits.
#
# The token is a dedicated, revocable Coder token named `omp-mcp`, not the
# operator session token. Rotate with:
# scripts/coder-token-rotate.sh
# List/revoke with:
# sudo -n docker exec coder /opt/coder tokens list
# sudo -n docker exec coder /opt/coder tokens remove <id>
set -euo pipefail
TOKEN_FILE="/home/loca/dev/coder/.mcp-token"
CRED="/home/loca/dev/coder/.admin-credentials"
CONTAINER="coder"
[[ -r "$TOKEN_FILE" ]] || { echo "missing token file: $TOKEN_FILE" >&2; exit 1; }
[[ -r "$CRED" ]] || { echo "missing credentials file: $CRED" >&2; exit 1; }
url="$(sed -n 's/^url=//p' "$CRED")"
[[ -n "$url" ]] || { echo "no url= line in $CRED" >&2; exit 1; }
ENVF="$(mktemp)"
chmod 600 "$ENVF"
trap 'rm -f "$ENVF"' EXIT
{
printf 'CODER_URL=%s\n' "$url"
printf 'CODER_SESSION_TOKEN=%s\n' "$(tr -d '[:space:]' <"$TOKEN_FILE")"
} >"$ENVF"
# Tool allow-list. Template mutation is deliberately excluded: pushing a
# template is the one identified path from a Coder principal to the host docker
# socket, so the agent gets read-only template access and full workspace access.
# Excluded on purpose: coder_create_template, coder_create_template_version,
# coder_update_template_active_version, coder_delete_template,
# coder_delete_task, coder_upload_tar_file.
ALLOWED="coder_get_authenticated_user,\
coder_list_workspaces,coder_get_workspace,coder_create_workspace,coder_create_workspace_build,\
coder_workspace_bash,coder_workspace_ls,coder_workspace_read_file,coder_workspace_write_file,\
coder_workspace_edit_file,coder_workspace_edit_files,coder_workspace_list_apps,\
coder_workspace_port_forward,coder_get_workspace_agent_logs,coder_get_workspace_build_logs,\
coder_list_templates,coder_get_template,coder_template_version_parameters,\
coder_get_template_version_logs,\
coder_create_task,coder_list_tasks,coder_get_task_status,coder_get_task_logs,\
coder_send_task_input,\
search,fetch"
# Foreground on purpose. Backgrounding this (`&` + `wait`) breaks the server:
# with job control disabled, bash redirects an async command's stdin from
# /dev/null, so the MCP stdio transport sees EOF immediately and the client
# reports "MCP tool load failed: Transport closed". Running in the foreground
# keeps the client's stdin pipe attached; the child shares this process group,
# so group-directed signals reach it directly, and the EXIT trap still shreds
# the env file once it exits (including on stdin close).
sudo -n docker exec -i --env-file "$ENVF" "$CONTAINER" \
/opt/coder exp mcp server --allowed-tools "$ALLOWED"

50
scripts/coder-token-rotate.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Rotate the dedicated Coder token used by the omp MCP integration.
#
# Coder caps token lifetime at 168h (7d) on this deployment, so this must be run
# weekly or the agent's Coder tools start returning 401.
#
# Mints a fresh `omp-mcp` token, writes it to .mcp-token (mode 600), then
# revokes every older token of the same name. The value is never printed.
set -euo pipefail
ROOT="/home/loca/dev/coder"
CRED="$ROOT/.admin-credentials"
TOKEN_FILE="$ROOT/.mcp-token"
CONTAINER="coder"
NAME="omp-mcp"
LIFETIME="168h"
[[ -r "$CRED" ]] || { echo "missing credentials file: $CRED" >&2; exit 1; }
url="$(sed -n 's/^url=//p' "$CRED")"
session="$(sed -n 's/^session_token=//p' "$CRED")"
[[ -n "$url" && -n "$session" ]] || { echo "url= or session_token= missing in $CRED" >&2; exit 1; }
ENVF="$(mktemp)"
chmod 600 "$ENVF"
trap 'rm -f "$ENVF"' EXIT
printf 'CODER_URL=%s\nCODER_SESSION_TOKEN=%s\n' "$url" "$session" >"$ENVF"
coder() { sudo -n docker exec -i --env-file "$ENVF" "$CONTAINER" /opt/coder "$@"; }
# Existing token ids for this name, captured before minting the replacement.
old_ids="$(coder tokens list | awk -v n="$NAME" 'NR>1 && $2==n {print $1}')"
new="$(coder tokens create --name "$NAME" --lifetime "$LIFETIME" | tail -n1 | tr -d '[:space:]')"
[[ -n "$new" ]] || { echo "token creation produced no value" >&2; exit 1; }
umask 077
printf '%s\n' "$new" >"$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
# Verify before revoking the old ones, so a bad mint cannot lock us out.
code="$(curl -s -o /dev/null -w '%{http_code}' -H "Coder-Session-Token: $new" "$url/api/v2/users/me")"
[[ "$code" == "200" ]] || { echo "new token failed verification (http $code); old tokens left intact" >&2; exit 1; }
for id in $old_ids; do
coder tokens remove "$id" >/dev/null && echo "revoked old token $id"
done
echo "rotated $NAME; expires in $LIFETIME"
coder tokens list