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

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