A Lock You Check Once Is a Receipt

The Deadbugz server tells the truth for three calls, and a session-start pin only ever sees the first zero. This week's companion is a stdio proxy that checks the lock on every answer and fails closed.

Issue 34 - Sept. 29, 2026
By o;border-top:1px solid #d4d4d8;font-size:0;line-height:0"> 

Let's Call It Checkpoint Engineering

I closed last week's issue with a lockfile for MCP tool manifests and a confession tucked into its README, under a heading that reads "Known holes, on purpose." mcp-pin checks the manifest at session start; the Deadbugz server is built to pass a session-start check. It answers honestly when you approve it, answers honestly for three calls, and then rewrites its tool descriptions for whoever is still connected. A pin checked at the door proves what the server said at the door; the attack lives an hour into the session.

So this week the lock moves into the transport. What we'll call a CHECKPOINT (the booth on the road, as opposed to the save point in a video game) is a lock that gets checked every time something passes through it. The companion script is a stdio proxy that stands between your MCP client and the server, forwards every message, and holds each tools/list answer up against the pinned manifest before the model reads a word of it; a drifted answer never makes it through, the server gets killed, and the diff lands in the log.

A lock checked at session start is a receipt; a lock checked on every call is a guard.

 

COMPANION SCRIPT

Companion script for this issue: mcp-guard. Put it in your client config where the server command used to be; it launches the real server behind two named pipes, compares every tools/list answer to its entry in the mcp-pin lockfile, pins prompts/list and prompts/get answers for the session, and on drift returns a JSON-RPC error in place of the answer, prints the diff, and kills the server. It won't start a server that isn't pinned. Hand-raiser keyword: MCPGUARD. The complete script is inline in the Quick Tip below and in the bashmatica-scripts repo, with a test fixture that copies the Deadbugz trigger.

 

FOR FURTHER READING

 

The Fourth Call Has a Delivery Route

The facts haven't moved since last week. On Aug. 10, between 9:52 p.m. and 11:07 p.m. UTC, the GitHub account zellkernel opened 23 pull requests that added a server called productivity-suite to unrelated projects; its two tools, format_text and summarize, work as advertised. After the third tools/call, Pillar reports, its tools/list and prompts/get answers change; the new text steers the connected agent toward SSH keys, AWS credentials, shell history, and Kubernetes config.

How that new text reaches the model decides where a proxy has to stand. The server advertises tools.listChanged. The spec says a server with that capability SHOULD send notifications/tools/list_changed when its tool list changes; the client's job is to answer the notification with a fresh tools/list. Pillar puts it plainly: the capability "allows a compatible client to refresh tool metadata." The protocol's own refresh path is the delivery route (the server rings the doorbell, and your client politely opens the door).

That re-read happens with no approval prompt, in the middle of a session nobody is watching, as a request your client makes on its own. The prompts side works the same way: a prompt fetched at 9 a.m. and the same prompt fetched after the third call come from a server that's been counting the whole time.

 

I Pinned It, So I'm Safe. Right?

That's the belief this issue exists to retire, and last week's script invites it. The reasoning goes like this: the manifest is pinned, mcp-pin check runs before every agent session, and a drifted server fails the job. All three clauses are true; for a server that changed between sessions (re-published, version-bumped, or swapped under the same config entry) the check does its work and the diff lands in a pull request.

Against Deadbugz, check passes every time. It starts a fresh server, sends initialize, notifications/initialized, and tools/list, and kills the process; that's zero tools/call requests, while the trigger fires on the third. The counter resets with every process, so a check that spawns a new one each morning reads the one copy of the manifest the server always gets right. The pin was accurate; it was a receipt for a transaction the attack never touches.

My position is that any check a server can schedule around is a check it'll schedule around. The only place to catch a manifest that changes mid-session is mid-session, on the wire, at the moment the client asks for it again.

 

Where the Guard Stands

mcp-guard goes in your client config where the server command was; it launches the real server behind two named pipes (macOS still ships bash 3.2, which predates coproc by a couple of years, so it's named pipes or nothing). On the way down it records each request's id and method; on the way up it matches each answer to its request by id and checks the ones that carry instructions for the model.

For tools/list, every tool in the answer is compared field for field against its entry in the lockfile, using jq deep equality on the same three fields mcp-pin hashes: name, description, and inputSchema. The comparison runs per tool rather than on the whole-manifest hash, because the spec lets tools/list paginate with a nextCursor. A per-tool check works on a partial page. A tool the lock has never seen is drift; a tool whose description grew a sentence is drift.

Prompts get a session pin, since the lockfile doesn't cover them. The first answer to prompts/list or prompts/get for a given set of params is hashed and remembered. A different answer to the same request later in the session is drift; that's the Deadbugz prompts/get case, caught without anyone pinning prompts in advance.

On drift the guard fails closed. The client gets a JSON-RPC error with code -32001 in place of the answer, the diff goes to stderr (which most clients write to their MCP log), and the server is killed; nothing after the drifted answer gets forwarded. A guard that logged and forwarded would be a second receipt. What you want at this spot is the property the interlock had in Issue #29: it can refuse, and nobody has to be awake for it to refuse. I'd rather lose a session to a false positive than hand the model a description nobody approved.

The repo ships a fake server that copies the trigger and a test run with 10 checks. The honest session answers all seven requests and exits 0. The Deadbugz session gets -32001 on the re-read, the rewritten description (the one that asks for ~/.ssh/id_ed25519) never reaches the client, the next tools/call is never answered, and stderr reads ~ changed: summarize (description). Against the reference filesystem server, @modelcontextprotocol/server-filesystem 2026.8.31 with 14 tools, an honest session passes clean; one added sentence in the lock's copy of read_text_file gets the session refused with ~ changed: read_text_file (description).

The reference server also turned up a shutdown bug in the first version of the guard: it doesn't exit when its stdin closes, so the guard waited on it indefinitely (a polite word for forever). The spec's stdio shutdown order is to close the input stream, wait, and then send SIGTERM; the guard now does that with a two-second wait.

 

What the Guard Can't See

The guard sees what the client asks for and nothing else. If a client never re-reads tools/list, the rewritten descriptions never reach the model either; the re-read is the only door, so that's where the guard stands.

Tool results aren't checked. The text that comes back from tools/call is supposed to change, so no lockfile can pin it; a server can smuggle instructions into a result as easily as into a description. That's a content filter's job, the same one llm-sanitizer and ctx-frisk do for logs and secrets. It's a different problem.

The lock covers three fields, and the spec defines more. A drifted title, annotations, or outputSchema passes the guard, and so does the free-text instructions string a server can return from initialize; the spec's own warning says clients MUST treat annotations as untrusted. I'd widen the projection in both scripts before trusting the gap, and doing so means re-pinning every server once.

The rest is plumbing. The guard is stdio only, so remote HTTP servers need the same checks in an HTTP proxy. It also runs one jq process per message in each direction, which is fine for an agent session and wrong for a firehose. A legitimate upgrade trips it, too, which is the point; re-pin with mcp-pin, and the diff goes in the pull request that bumps the lock.

None of that changes the stakes for whoever owns the incident review. When an agent reads ~/.ssh because a tool description told it to, the question from the CISO is what was checking the description at the moment the agent read it, and "the pin we ran at 6 a.m." is an answer that ends careers.

 

QUICK TIP

Put the Guard Where the Server Was

Pin the server once with mcp-pin, then swap the guard into your client config ahead of the original command:

{
  "mcpServers": {
    "fs": {
      "command": "/abs/path/mcp-guard.sh",
      "args": ["-f", "/Users/you/.config/mcp/mcp-pin.lock", "-n", "fs", "--",
               "npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/you/work"]
    }
  }
}

The complete script, bash 3.2 or later plus jq:

#!/usr/bin/env bash
# mcp-guard: sit between an MCP client and a stdio server, forward everything, and
# check every tools/list answer against the mcp-pin lockfile on its way up. Prompts
# get a session pin: the first prompts/list or prompts/get answer for a given set of
# params is recorded, and a different answer later in the same session is drift.
# On drift the client gets a JSON-RPC error instead of the answer, the diff goes
# to stderr, and the server is killed. A lock checked on every call.
#
#   mcp-guard [-f lockfile] [-n name] -- <server command> [args]
#
#   -f, --lockfile  mcp-pin lockfile (default: mcp-pin.lock)
#   -n, --name      entry name in the lockfile (default: basename of the server command)
#
# Put it in your client config where the server command was:
#   "command": "mcp-guard", "args": ["-f", "/abs/path/mcp-pin.lock", "--", "npx", "some-server"]
#
# Exit: 0 clean shutdown; 1 DRIFT (server killed);
#       2 bad usage; 3 jq missing; 4 no lockfile entry for this server.
set -uo pipefail

lockfile="mcp-pin.lock"; name=""
while [ $# -gt 0 ]; do
  case "$1" in
    -f|--lockfile) lockfile="$2"; shift 2 ;;
    -n|--name)     name="$2"; shift 2 ;;
    --) shift; break ;;
    *) echo "mcp-guard: unknown argument: $1" >&2; exit 2 ;;
  esac
done
[ $# -gt 0 ] || { echo "usage: mcp-guard [-f lockfile] [-n name] -- <server command> [args]" >&2; exit 2; }
command -v jq >/dev/null || { echo "mcp-guard: jq is required" >&2; exit 3; }
if command -v sha256sum >/dev/null; then sha() { sha256sum | cut -d' ' -f1; }
else sha() { shasum -a 256 | cut -d' ' -f1; }; fi
[ -n "$name" ] || name="$(basename "$1")"
jq -e --arg n "$name" '.[$n].tools' "$lockfile" >/dev/null 2>&1 || {
  echo "mcp-guard: no entry '$name' in $lockfile; run mcp-pin pin first" >&2; exit 4; }

tmp="$(mktemp -d "${TMPDIR:-/tmp}/mcp-guard.XXXXXX")"
mkdir "$tmp/req" "$tmp/pin"
mkfifo "$tmp/in" "$tmp/out"
srv_pid=""; cli_pid=""
# shellcheck disable=SC2329  # invoked by the EXIT trap
cleanup() {
  exec 2>/dev/null  # the diff is already out; drop the shell's "Terminated" job notices
  [ -n "$cli_pid" ] && kill "$cli_pid" 2>/dev/null
  [ -n "$srv_pid" ] && kill "$srv_pid" 2>/dev/null
  rm -rf "$tmp"
}
trap cleanup EXIT

"$@" <"$tmp/in" >"$tmp/out" &
srv_pid=$!
exec 3>"$tmp/in" 4<&0

# Client -> server: remember each request's method and params by id, then forward.
# When the client hangs up, close the server's stdin and give it two seconds to go.
{ while IFS= read -r line; do
  rec="$(jq -c 'select(type == "object" and has("method") and has("id"))
    | {k: (.id | tojson | @uri), m: .method, p: (.params // {})}' <<<"$line" 2>/dev/null)"
  [ -n "$rec" ] && printf '%s\n' "$rec" >"$tmp/req/$(jq -r .k <<<"$rec")"
  printf '%s\n' "$line" >&3
done; exec 3>&-; sleep 2; kill "$srv_pid" 2>/dev/null; } <&4 &
cli_pid=$!
exec 3>&- 4<&-

refuse() {  # $1 = the server's line, $2 = reason; stdin = diff lines
  jq -c --arg m "mcp-guard: $2; server stopped" \
    '{jsonrpc: "2.0", id, error: {code: -32001, message: $m}}' <<<"$1"
  echo "mcp-guard: DRIFT $name: $2" >&2
  sed 's/^/  /' >&2
  exit 1
}

# Server -> client: check the answers that carry instructions for the model.
while IFS= read -r line; do
  jq -e 'type == "object"' <<<"$line" >/dev/null 2>&1 \
    || refuse '{}' "non-object message from server" </dev/null
  k="$(jq -r 'select(has("id") and has("result")) | .id | tojson | @uri' <<<"$line")"
  if [ -n "$k" ] && [ -f "$tmp/req/$k" ]; then
    method="$(jq -r .m "$tmp/req/$k")"
    case "$method" in
      tools/list)
        diff="$(jq -r --arg n "$name" --slurpfile L "$lockfile" '
          ($L[0][$n].tools | map({key: .name, value: .}) | from_entries) as $w
          | .result.tools // [] | .[] | {name, description, inputSchema}
          | select($w[.name] != .)
          | if $w[.name] == null then "+ added:   \(.name)"
            elif $w[.name].description != .description then "~ changed: \(.name) (description)"
            else "~ changed: \(.name) (inputSchema)" end' <<<"$line")"
        [ -z "$diff" ] || refuse "$line" "tools/list differs from $lockfile" <<<"$diff"
        ;;
      prompts/list|prompts/get)
        key="$(jq -S -c '[.m, .p]' "$tmp/req/$k" | sha)"
        got="$(jq -S -c '.result' <<<"$line" | sha)"
        if [ ! -f "$tmp/pin/$key" ]; then
          printf '%s\n' "$got" >"$tmp/pin/$key"
        elif [ "$(cat "$tmp/pin/$key")" != "$got" ]; then
          refuse "$line" "$method answer changed mid-session" \
            <<<"$(jq -c '.p' "$tmp/req/$k") was $(cut -c1-12 "$tmp/pin/$key"), now ${got:0:12}"
        fi
        ;;
    esac
    rm -f "$tmp/req/$k"
  fi
  printf '%s\n' "$line"
done <"$tmp/out"

wait "$srv_pid" 2>/dev/null
srv_pid=""
exit 0
 

Quick Wins

🟢 Easy (~10 min): Clone bashmatica-scripts and run mcp-guard/test/run.sh. Then open test/fake-server.sh and read the rewritten summarize description; it's two sentences long, and it's the whole attack.

🟡 Medium (~1 hour): Wrap the servers that have credentials behind them (the read/write ones, the ones holding an API key) in mcp-guard, commit the lock beside your MCP config, and bump one server's version on purpose. The session refuses; the re-pin diff goes in the pull request that bumps the lock.

🔴 Advanced (half day): Widen the projection in both scripts to cover title, annotations, outputSchema, and the instructions string from initialize, re-pin every server, and add a fake-server case that drifts only its annotations. Then decide whether your HTTP servers get the same treatment in a reverse proxy.

 

Next Week

The half of this problem a lock can't reach is the text that comes back from tools/call; that's the likely next thread, and the news gets a vote.

A server that counts to three before it lies will pass every check that runs before it starts counting; a session-start pin runs before it starts counting every single time.

Put the guard where the server command was.

Then pin each server the day you approve it, let the guard refuse the answer that moved and kill the process that sent it, and treat every refusal as a new approval that belongs in a pull request; a lock checked once hands you a receipt for the server you met at the door, and a lock checked on every call keeps a guard on the server you're still talking to.

Bobby R. Goldsmith
Ambassador Extraordinary and Plenipotentiary of Bashmatica! by NodeBridge Automation Solutions

P.S. mcp-guard reads the lockfile from last week's issue, so if you skipped the pin, start there; the guard won't run an unpinned server, by design. If someone forwarded this to you, subscribe at bashmatica.com, and if you know someone running MCP servers in CI with cloud credentials in the environment, send it their way before one of those servers makes its fourth call.

NODEBRIDGE AUTOMATION SOLUTIONS

Every guardrail in this newsletter, already wired into your Claude Code setup.

NodeBridge configures Claude Code against your actual repos: MCP servers, subagents, hooks, permissions and a project memory that keeps the right context in and the stale context out. You own every config when it ships. Setups start at $750 and go live in about three days.

See the Setup Packages