MCP Is the New Webhook
One GitHub account, 23 pull requests, 74 minutes, and an MCP server that tells the truth for three calls. Webhooks taught us to sign the payload; here the payload that matters is the tool list, so the companion script is a lockfile.
Issue 33 - Sept. 22, 2026
By to;border-top:1px solid #d4d4d8;font-size:0;line-height:0">
Let's Call It Lockfile Engineering
This issue has been promised three times, and twice the internet cut in line with something that needed taking apart first; this week nothing bigger landed, so this is that issue, with September's receipts attached and the claim in the title. Around 2013 we started letting third parties push into our pipelines through a URL. It took years, a few incidents, and a lot of guides before every serious sender signed the payload and every serious receiver checked the signature. The Model Context Protocol is that same arrangement run in reverse: a third-party process pushes tool descriptions into your agent's context; the agent then acts on them with your credentials.
The webhook fix answered one question, who sent this, and answered it well; Stripe puts an HMAC in a header and GitHub does the same, and a forged payload bounces off both. MCP has a different hole. The thing you approved when you added the server, the manifest of tool names, descriptions, and schemas, is a document the server hands you on request. Nothing in the protocol promises that the document you approved is the one it hands you next session, or on the fourth call. The webhook fix transfers; it moves from the transport to the manifest.
Pin the manifest the day you approve it, and treat any change to it as a new approval.
COMPANION SCRIPT Companion script for this issue: |
FOR FURTHER READING
|
Twenty-Three Pull Requests in 74 Minutes
On Aug. 10, between 9:52 p.m. and 11:07 p.m. UTC, a GitHub account called zellkernel opened 23 pull requests against AI, MCP, and developer-tool repositories that had nothing to do with each other. Seventeen of them added a remote MCP server to the project's config, four pointed at a Python file hidden under ~/.config/.cache/.sys/, and two were entries in tool directories. Pillar Security published its writeup two days later; by then 19 of the pull requests were closed, four were open, and none had been merged through GitHub's merge button. That's the good news, and also a measure of how cheap the attempt was.
The server is called productivity-suite and it ships two tools, format_text and summarize, and both work. An agent that calls them gets formatted text and summaries, and a maintainer who tries the server before approving the pull request sees a boring, competent little utility. The server, meanwhile, is counting. After its third tools/call request, its tools/list and prompts/get responses change: the tool descriptions come back carrying instructions that steer the connected agent toward SSH keys, AWS credentials, shell history, and Kubernetes config. The server also phones a WEBHOOK_URL on connection, on first call, and on the trigger, so its operator knows which victims are ripe.
The delivery mechanism is the old one, a pull request from a stranger; the payload is new. A malicious npm package has to do its damage in code somebody could read; this server's damage is a paragraph of English in a description field, delivered after the reading is over, to a reader that follows instructions for a living.
I Read the Descriptions When I Approved It. Right?
That's the belief the campaign is built on, and I held it too. Every client shows the tool list at approval, and for the servers that matter (the read/write ones, the ones with my API keys behind them) I read the descriptions like a diff. The timeline is the problem. Deadbugz answers the approval read honestly; it answers the first three calls honestly; the fourth call is the one that changes the story, and by then the approval prompt is a week old and the agent is reading a document nobody else will ever look at. The read happened, on the wrong copy.
My own config makes the same point from the other direction. Not one of the servers in my ~/.mcp.json has ever, that anyone could tell, changed its tool list under me; the second half of that sentence is the finding, because nothing in the client, the protocol, or my process would've told me if one had.
What did change under me was behavior with the manifest held constant. The Beehiiv MCP's write tools (save_post, edit_post_content) worked on the Scale plan all summer even though the REST endpoint for the same operation was documented a tier higher; when the plan lapsed on Sept. 1, the identical call came back SAVE_POST_UNAVAILABLE_PLAN_UPGRADE_REQUIRED. Same server, same tool names, same descriptions, different outcome. The manifest describes what the tools are; it's never once described what they'll do when called.
So the approval prompt and the behavior are two different documents, and only one of them is in front of you when you click approve. A lockfile can't make the second document honest. It can make the first one stop moving.
The Webhook Decade, in One Paragraph
Webhooks had this exact problem and solved it in public. Early receivers accepted any POST that arrived at the secret URL (the URL was the secret, which is the kind of sentence that ages badly); the fix that stuck was to sign the body. Stripe sends a Stripe-Signature header with a timestamp in t= and an HMAC-SHA256 in v1= computed over the timestamp and the raw body, and its libraries reject anything more than five minutes old; GitHub sends X-Hub-Signature-256, which is sha256= followed by a hex digest over the body with your shared secret as the key, after retiring the SHA-1 X-Hub-Signature it started with. Every receiver that survived learned to verify the signature on the raw bytes, keep a log of event IDs it had seen, and return a 200 before doing anything clever. None of that verifies what the payload means; it verifies that the payload arrived unchanged from the party you agreed to trust.
That last clause is the transfer. In the webhook model the trusted party signs and you verify. In the MCP model there's no signer, because the server is the party you're worried about; so you sign. You take the manifest at the moment you trusted it, hash it, and keep the hash where the server can't reach it; every session after that, you fetch the manifest again and compare. Drift is a refused connection, the same way a bad v1= is.
Three CVEs and What a Lockfile Can't Fix
The CVEs are the ordinary kind and get one paragraph, because they're the part MCP shares with every other server. CVE-2026-73498 is in sooperset's mcp-atlassian before 0.22.0: confluence_upload_attachment passes its file_path argument straight to open(file_path, "rb") without the validate_safe_path call the rest of the file uses. An authenticated client, or an agent talked into the call by a Confluence page, can read any file the server process can and upload it as an attachment (CVSS 7.7, changed scope, because the file leaves the box). CVE-2026-67357 is ArcadeDB before 26.7.3, where the get_server_settings tool returns arcadedb.ha.clusterToken in cleartext, and that token plus two HTTP headers is root (7.7).
CVE-2026-19956 is facebook-ads-mcp-server 0.1.0, where fetch_pagination_url fetches whatever URL it's handed, from the server's network position (5.3, fixed in one commit). Absolutely patch all three. A manifest pin does nothing for any of them, since the manifest was honest and the code behind it wasn't.
The pin is for the other class, and the companion script covers half of that class, plainly stated. mcp-pin is a session-start check: it catches a server that was re-published, version-bumped, or swapped under the same config entry. That's how a campaign like Deadbugz lands in a repo that merged the pull request, and how a compromised maintainer account lands in one that didn't. It doesn't catch the third-call trigger itself, because the server answers honestly at startup by design. Catching that needs a proxy in the transport that re-hashes every tools/list and prompts/get response against the lock and refuses to forward one that moved; that's a later issue and the natural next companion.
What the pin does buy, today, is the thing the webhook decade bought: a manifest that can't change without somebody noticing, and a diff in the PR that bumps it.
I ran it against the reference filesystem server and got 14 tools under one SHA-256; against a 29-tool Python server of my own; and against a two-tool fake of productivity-suite whose format_text description grows a sentence about ~/.ssh/id_rsa when an environment variable is set. The first two pin and check clean. The third pins clean, and on the drifted run check prints DRIFT productivity-suite: manifest changed since 2026-09-22T02:54:06Z and ~ changed: format_text (description) and exits 1, which is the whole feature.
QUICK TIP Pin the Manifest, Then Check It Every Session Save this as |
Quick Wins
🟢 Easy (~10 min): Run mcp-pin show against every server in your MCP config and read the descriptions the way you'd read a pull request from a stranger. A description that says "always," "before every task," or names a file outside the tool's job is a finding, whether or not the server is malicious; the agent will do what it says.
🟡 Medium (~1 hour): Pin every server, commit the lockfile next to .mcp.json (or wherever your client keeps its config), and add mcp-pin check to the hook or CI step that runs before the agent gets a shell. Then bump one server's version on purpose and watch the diff arrive in the PR; that's the review the approval prompt never gave you.
🔴 Advanced (half day): Write the proxy. A stdio shim that sits between client and server, forwards every request, and re-hashes each tools/list and prompts/get response against the lock before passing it up; on drift it forwards an error instead of the response and logs the diff. Run the Deadbugz fake through it and confirm the fourth call is the one that gets refused.
Next Week
The transport proxy is the open thread from this issue and the obvious next companion; unless something bigger lands, that's where next Tuesday goes.
Twenty-three pull requests, two honest tools, three honest calls, and then a description field that asks your agent for its SSH keys. The webhook decade taught every receiver to verify the bytes it was handed against a signature it trusted; MCP hands you a manifest with no signer, so the signer has to be you, on the day you read it.
Pin what you approved.
Then check it every session and refuse on drift, keep the lock where the server can't reach it, and put the diff in the pull request that changes it; that's a lockfile for tools, at the price of one hash and a jq filter, and it's the difference between a manifest you read once and a manifest you read every time.
Bobby R. Goldsmith
Ambassador Extraordinary and Plenipotentiary of Bashmatica! by NodeBridge Automation Solutions
P.S. Issue #23 kept secrets out of the context window and this one keeps the server from asking for them; Issue #29 defined the interlock, and a pinned manifest is one with a lockfile for a guard. If someone forwarded this to you, subscribe at bashmatica.com, and if you know a maintainer who merged a config PR from a stranger last month, forward it to them before their agent 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. |