diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 77434cd..3079e8b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "skills", "description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.", - "version": "0.18.1", + "version": "0.19.0", "author": { "name": "Mathias", "url": "https://git.d-ma.be/mathias" diff --git a/.mcp.json b/.mcp.json index 6c6b53d..4068dad 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,5 +1,5 @@ { - "_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24.", + "_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24. Every ${VAR} below must be one ~/.op-env defines -- bin/check-mcp-env enforces that, because an undefined one sends an empty Authorization header and reports as 'requires authentication', which is indistinguishable from a revoked credential.", "mcpServers": { "brain": { "type": "http", @@ -11,23 +11,24 @@ "infra": { "type": "http", "url": "https://koala.tail30472e.ts.net:8444/mcp" + }, + "gitea": { + "_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT, which the server validates against Gitea and then acts as. So no dedicated MCP token is needed, and the AgentSecrets blocker that parked this entry (skills#32, #33) never applied to it -- DMABE_GITEA_API_TOKEN is op://HomeLab and already in the session environment. Verified 2026-09-07: initialize 200 (serverInfo gitea-mcp) and tools/list returns 41 tools. Two DIFFERENT PATs both authenticate while GITEA_MCP_TOKEN 401s, which is what proves this is pass-through validation and not a static-token comparison.", + "type": "http", + "url": "https://koala.tail30472e.ts.net:8446/mcp", + "headers": { + "Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}" + } } }, "_parked": { - "_reason": "cad and gitea-mcp are proxied, reachable and AUTHENTICATING: tailscale serve 8445 -> 127.0.0.1:31877 and 8446 -> 127.0.0.1:30340; an MCP initialize with the correct bearer returns 200 from both (serverInfo gitea-mcp 0.1.0, cad aed2aaf). They stay parked only because nothing puts those two tokens into the session environment. Both live in the 1Password AgentSecrets vault, and by deliberate design (infra#224, closed) no single identity on koala reads both vaults: koala-homelab is HomeLab-only and resolves ~/.op-env, koala-agentsecrets-write is AgentSecrets-only and lives in ~/.op-agent-write.env. Adding an AgentSecrets reference to ~/.op-env does NOT degrade gracefully -- `op run` fails wholesale and every other token, BRAIN_MCP_TOKEN included, goes unresolved with it. Enabling needs a read-scoped AgentSecrets identity in the session-env path; see mathias/skills#32, #33.", + "_reason": "cad is proxied, reachable and AUTHENTICATING: tailscale serve 8445 -> 127.0.0.1:31877, and an MCP initialize with the correct bearer returns 200 (serverInfo cad aed2aaf). It stays parked only because nothing puts CAD_MCP_TOKEN into the session environment. It lives in the 1Password AgentSecrets vault, and by deliberate design (infra#224, closed) no single identity on koala reads both vaults: koala-homelab is HomeLab-only and resolves ~/.op-env, koala-agentsecrets-write is AgentSecrets-only and lives in ~/.op-agent-write.env. Adding an AgentSecrets reference to ~/.op-env does NOT degrade gracefully -- `op run` fails wholesale and every other token, BRAIN_MCP_TOKEN included, goes unresolved with it. Enabling needs a read-scoped AgentSecrets identity in the session-env path; see mathias/skills#32, #33. gitea was parked here for the same stated reason and has been unparked: it needs no AgentSecrets token at all, because gitea-mcp is pass-through-authenticated.", "cad": { "type": "http", "url": "https://koala.tail30472e.ts.net:8445/mcp", "headers": { "Authorization": "Bearer ${CAD_MCP_TOKEN}" } - }, - "gitea": { - "type": "http", - "url": "https://koala.tail30472e.ts.net:8446/mcp", - "headers": { - "Authorization": "Bearer ${GITEA_MCP_STATIC_TOKEN}" - } } } } diff --git a/Taskfile.yml b/Taskfile.yml index 9a745c7..00e16a9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -138,6 +138,21 @@ tasks: echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1 fi + # Every ${VAR} a live .mcp.json server interpolates must be one the + # session env-file defines. An undefined one is silent: the header + # goes out empty and the server reports "requires authentication", + # which reads as a revoked credential. Exit 2 means the check could + # not run (no jq, or no env-file — CI has neither); that is reported + # and not fatal, but it is never treated as a pass. + if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then + "{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" + case $? in + 0) ;; + 2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;; + *) fail=1 ;; + esac + fi + if [ $fail -ne 0 ]; then echo "check FAILED" >&2; exit 1 fi diff --git a/bin/check-mcp-env b/bin/check-mcp-env new file mode 100755 index 0000000..b20d8e7 --- /dev/null +++ b/bin/check-mcp-env @@ -0,0 +1,89 @@ +#!/bin/sh +# check-mcp-env — assert every variable a live MCP server interpolates is one +# the session env-file actually defines. +# +# Why this is a gate and not a comment: the failure is silent. A header of +# "Bearer ${FOO}" where FOO is undefined does not error -- it goes out empty, +# the server answers 401, and Claude Code reports "requires authentication", +# which reads exactly like a revoked credential. On 2026-09-07 that cost a +# full investigation on koala before the credential turned out to be fine. +# +# Servers under `_parked` are exempt by design. Parked means "reachable, but +# nothing puts its token in the environment yet" -- flagging those would +# report the intended state as a fault. +# +# Usage: check-mcp-env +# +# Exit 0 every live server's variables are defined +# 1 at least one is not, named on stderr +# 2 could not verify (bad usage, unreadable input, jq absent). +# Deliberately distinct from 0: an unverified config is not a clean +# one, and a gate that cannot run should say so rather than pass. + +set -u + +CONFIG=${1:-} +ENVFILE=${2:-} + +if [ -z "$CONFIG" ] || [ -z "$ENVFILE" ]; then + echo "usage: check-mcp-env " >&2 + exit 2 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "SKIP: jq not installed -- MCP env wiring unverified" >&2 + exit 2 +fi + +if [ ! -f "$CONFIG" ]; then + echo "ERROR: no such config: $CONFIG" >&2 + exit 2 +fi + +if [ ! -f "$ENVFILE" ]; then + echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2 + exit 2 +fi + +# Every "\t" pair a live server interpolates. `..` walks the whole +# value, so a variable is found wherever it is placed, not only under headers. +refs=$(jq -r ' + .mcpServers // {} + | to_entries[] + | .key as $server + | (.value | .. | strings) + | select(test("\\$\\{")) + | [ scan("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}")[] ] + | .[] + | "\($server)\t\(.)" +' "$CONFIG" 2>/dev/null) || { + echo "ERROR: $CONFIG is not valid JSON -- MCP env wiring unverified" >&2 + exit 2 +} + +if [ -z "$refs" ]; then + echo "check-mcp-env: no interpolated variables in live servers" + exit 0 +fi + +fail=0 +checked=0 +for pair in $(printf '%s\n' "$refs" | tr '\t' ':'); do + server=${pair%%:*} + var=${pair#*:} + checked=$((checked + 1)) + if grep -qE "^[[:space:]]*(export[[:space:]]+)?$var=" "$ENVFILE"; then + continue + fi + printf 'ERROR: MCP server %s interpolates %s, which %s does not define.\n' \ + "$server" "$var" "$ENVFILE" >&2 + printf ' It will send an empty Authorization header and report as "requires authentication".\n' >&2 + fail=1 +done + +if [ "$fail" -ne 0 ]; then + exit 1 +fi + +printf 'check-mcp-env: %d interpolated variable(s) across live MCP servers, all defined in %s\n' \ + "$checked" "$ENVFILE" diff --git a/tests/mcp_config_test.sh b/tests/mcp_config_test.sh new file mode 100755 index 0000000..1d13459 --- /dev/null +++ b/tests/mcp_config_test.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# Tests for bin/check-mcp-env. +# +# The fault this gate exists for is silent, which is why it needs a gate at +# all. An `.mcp.json` server whose Authorization header interpolates a +# variable nothing in the session environment defines does not error: the +# header goes out empty, the server answers 401, and Claude Code reports +# "requires authentication". That is indistinguishable from a revoked or +# expired credential, and on 2026-09-07 it cost a full investigation on koala +# to establish that the credential was fine and the variable was simply never +# set. +# +# So the property is: every ${VAR} a LIVE server interpolates must be a +# variable the session env-file actually defines. +# +# The `_parked` block is deliberately exempt. Parked servers reference +# variables nothing provides on purpose -- that is what parked means -- and a +# gate that flagged them would be reporting the intended state as a fault. +# +# Fixtures drive the checker through argv, so no test touches the real host. + +set -u + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +CHECK="$ROOT/bin/check-mcp-env" + +pass=0 +fail=0 +WORK=${TMPDIR:-/tmp}/mcp_config_test.$$ + +if [ ! -f "$CHECK" ]; then + printf 'FAIL: %s does not exist\n' "$CHECK" + exit 1 +fi + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT INT TERM +mkdir -p "$WORK" + +if ! command -v jq >/dev/null 2>&1; then + printf 'SKIP: jq not installed -- bin/check-mcp-env cannot be exercised\n' + exit 0 +fi + +# ok / bad +ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; } +bad() { fail=$((fail+1)); printf ' FAIL %s %s\n' "$1" "${2:-}"; } + +# run -> sets $rc and $out +run() { + out=$("$CHECK" "$1" "$2" 2>&1); rc=$? +} + +ENVFILE="$WORK/op-env" +cat > "$ENVFILE" <<'ENVEOF' +BRAIN_MCP_TOKEN=op://HomeLab/brain/token +DMABE_GITEA_API_TOKEN=op://HomeLab/gitea/token +ENVEOF + +# --- a live server whose variable IS defined --------------------------------- +cat > "$WORK/good.json" <<'JSONEOF' +{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp", + "headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}}} +JSONEOF +run "$WORK/good.json" "$ENVFILE" +[ "$rc" -eq 0 ] && ok "defined variable passes" || bad "defined variable passes" "rc=$rc $out" + +# --- a live server whose variable is NOT defined ------------------------------ +# The exact shape that broke: the server looks correct and fails at runtime. +cat > "$WORK/missing.json" <<'JSONEOF' +{"mcpServers":{"gitea":{"type":"http","url":"https://x/mcp", + "headers":{"Authorization":"Bearer ${GITEA_MCP_STATIC_TOKEN}"}}}} +JSONEOF +run "$WORK/missing.json" "$ENVFILE" +if [ "$rc" -ne 0 ]; then + case "$out" in + *gitea*GITEA_MCP_STATIC_TOKEN*) ok "undefined variable is flagged, naming server and variable" ;; + *) bad "undefined variable is flagged, naming server and variable" "report did not name both: $out" ;; + esac +else + bad "undefined variable is flagged, naming server and variable" "rc=0" +fi + +# --- a server with no headers at all (the `infra` shape) ---------------------- +# Unauthenticated by design. Nothing to resolve, so nothing to flag. +cat > "$WORK/noauth.json" <<'JSONEOF' +{"mcpServers":{"infra":{"type":"http","url":"https://x/mcp"}}} +JSONEOF +run "$WORK/noauth.json" "$ENVFILE" +[ "$rc" -eq 0 ] && ok "header-less server is not flagged" || bad "header-less server is not flagged" "rc=$rc $out" + +# --- parked servers are exempt ------------------------------------------------ +cat > "$WORK/parked.json" <<'JSONEOF' +{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp", + "headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}}, + "_parked":{"_reason":"no identity provides these yet", + "cad":{"type":"http","url":"https://x/mcp", + "headers":{"Authorization":"Bearer ${CAD_MCP_TOKEN}"}}}} +JSONEOF +run "$WORK/parked.json" "$ENVFILE" +if [ "$rc" -eq 0 ]; then + ok "parked server is exempt" +else + bad "parked server is exempt" "rc=$rc $out" +fi + +# --- malformed JSON must not pass silently ------------------------------------ +# A config the checker cannot read is an unverified config, not a clean one. +printf '{"mcpServers":{' > "$WORK/broken.json" +run "$WORK/broken.json" "$ENVFILE" +[ "$rc" -ne 0 ] && ok "unparseable config does not report success" || bad "unparseable config does not report success" "rc=0" + +# --- a missing env-file is unverifiable, not clean ---------------------------- +run "$WORK/good.json" "$WORK/no-such-env" +[ "$rc" -ne 0 ] && ok "missing env-file does not report success" || bad "missing env-file does not report success" "rc=0" + +printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$pass" "$fail" +[ "$fail" -eq 0 ]