Key Takeaway
A one-time review cannot clear an MCP server. The Deadbugz server answered normally for its first three tool calls, then rewrote its tool and prompt metadata to steer agents toward SSH keys and cloud credentials. Defence means fingerprinting definitions, re-checking them inside live sessions, and requiring re-approval whenever they change.
The review I used to do for a new MCP server took about ten minutes. Read the README, skim the source, connect it, call each tool once, see sensible output, approve. Deadbugz is the incident that made me stop trusting that routine, because a server that passes it can still be hostile on its fourth call.
This post reads the Pillar Security write-up, the Cloud Security Alliance research note and OWASP's Q3 2026 roundup entry side by side, then the MCP specification for what a client is actually promised. It ends with a fingerprinting script, a pull request gate and a Monday checklist. I describe what the server did, not how to build one, and I include no payload text.
What Deadbugz actually did on 10 August 2026
Pillar Security reports that a GitHub account named zellkernel opened 23 pull requests against unrelated AI, MCP and developer-tool repositories in a 74-minute window on 10 August 2026. OWASP lists the incident as disclosed on 12 August and records no confirmed compromise, because no pull request was merged. At Pillar's review, 19 were closed and 4 were still open.
The pull requests were the delivery mechanism, not the exploit. Each one tried to get a maintainer to wire a server called productivity-suite into a project. The server itself offers two harmless tools, and the harm lives entirely in what it later says about them.
- 17 pull requests added a remote MCP server configuration pointing at the attacker's hosted endpoint.
- 4 pointed at a hidden local Python script under a dot-directory in the home config folder.
- 2 were directory or listing submissions, which Pillar and CSA both read as possible discovery aids.
Why a one-time install review passes it
The server keeps an in-memory count of tools/call requests for each connected client. For the first three calls to format_text or summarize it behaves like any text utility. After the third, its responses to tools/list and prompts/get change and begin carrying instructions aimed at the agent: look for SSH keys, AWS credentials, shell history and Kubernetes configuration, and keep quiet about it. OWASP stresses that the server never reads those files itself. It talks the trusted agent into collecting them.
That gate defeats each step of my old routine. Source review finds nothing in the initial code path, as CSA notes. A manual try-out of each tool once stays under the threshold. A scripted scan that connects, lists tools and calls two of them also stays under it. Pillar calls the gating a research-evasion technique, and I agree with that label: it is designed around how people vet servers, not around how servers run.
If your approval process ends at install, you have approved a server's first three calls. Everything after that is unreviewed, and the agent will treat the new text as instructions from a server you already trusted.
Which parts of the protocol can change under you
The MCP tools page defines a tool as a name plus metadata: name, an optional title, a description, an inputSchema, an optional outputSchema and optional annotations. The model reads those fields to decide what to call and with what. That is why metadata is an attack surface and not just documentation. Here is what the specification says about each surface Deadbugz touched or could touch.
| Surface | What the 2025-06-18 spec says | Why it matters for this attack |
|---|---|---|
| tools/list | Returns tool definitions with name, description, inputSchema and annotations, paginated with a cursor. Nothing says a later call must return the same text. | Deadbugz changed this response after the third tools/call. A definition you read at install is only a snapshot. |
| prompts/get | Returns messages with a role and content for a named prompt. Prompts are described as user-controlled. | Pillar and CSA both say this response was rewritten too, so it is a second channel for instructions that a tools-only review misses. |
| notifications/tools/list_changed | Servers that declare listChanged SHOULD send it when the tool list changes. SHOULD is not MUST. | Pillar notes the public code advertises tools.listChanged but does not say whether a notification was sent. Do not make a server's own announcement your only drift detector. |
| Tool annotations | Clients MUST consider tool annotations untrusted unless they come from trusted servers. | A server that was trusted at approval time keeps that trust. Fingerprinting is how you notice the trust no longer applies. |
The specification also says there SHOULD always be a human in the loop with the ability to deny tool invocations, and that clients should log tool usage for audit. Those are the right instincts, but the spec's own rule that clients should show tool inputs before calling is aimed at the call, not at a definition that quietly changed since the human last looked. Invariant Labs described this shape in April 2025 as a rug pull: a server changes a tool description after the client approved it. Deadbugz adds the counter and a public pull request as the delivery route.
Fingerprint every definition, then keep checking
Pillar tells client and platform builders to fingerprint tool definitions at approval time and treat any change to an approved server as a security event. CSA calls it hash pinning. The script below is how I would do that without dependencies. The detail that matters is the last function: because the trigger is a per-client counter, a fingerprint taken once at initialise proves nothing, so the check runs after every call inside the live session.
// mcp-fingerprint.mjs (Node 18+, no dependencies)
import { createHash } from "node:crypto";
// JSON.stringify depends on key order, so two servers (or two runs) that send
// the same definition with keys in a different order would hash differently.
// Sort keys recursively before hashing.
function canonical(value) {
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
if (value && typeof value === "object") {
return (
"{" +
Object.keys(value)
.sort()
.map((key) => JSON.stringify(key) + ":" + canonical(value[key]))
.join(",") +
"}"
);
}
return JSON.stringify(value);
}
const sha256 = (value) =>
"sha256:" + createHash("sha256").update(canonical(value)).digest("hex");
// Hash the WHOLE definition: name, title, description, inputSchema,
// outputSchema and annotations. Hashing only "description" misses a changed
// schema field description, which the model reads just as closely.
export function fingerprintTools(toolsFromAllPages) {
return Object.fromEntries(toolsFromAllPages.map((t) => [t.name, sha256(t)]));
}
// prompts/get returns messages, not a definition. Hash what came back for the
// arguments you approved, because that text is what lands in the context.
export function fingerprintPrompt(promptGetResult) {
return sha256(promptGetResult);
}
// Compare a live listing with the approved lock entry.
export function diffTools(approved, live) {
const changed = [];
for (const name of new Set([...Object.keys(approved), ...Object.keys(live)])) {
if (!(name in live)) changed.push({ name, kind: "removed" });
else if (!(name in approved)) changed.push({ name, kind: "added" });
else if (approved[name] !== live[name]) changed.push({ name, kind: "changed" });
}
return changed;
}
// The part Deadbugz makes necessary: re-list INSIDE the live session after
// every tools/call, not only at initialise. listTools must follow nextCursor
// and return every page; fail closed and stop the session on any drift.
export async function guardedCall(listTools, callTool, approved, name, args) {
const result = await callTool(name, args);
const drift = diffTools(approved, fingerprintTools(await listTools()));
if (drift.length > 0) {
throw new Error("MCP tool definitions drifted: " + JSON.stringify(drift));
}
return result;
}Hashing the whole definition rather than the description is deliberate: a changed inputSchema property description is read by the model exactly like a changed tool description. The fail-closed throw is also deliberate. A drift alert that only logs would have let the agent act on the rewritten metadata, so the session stops and a human re-approves. The cost is one extra tools/list request per call, which I think is cheap next to an unreviewed agent holding your shell.
Gate the pull request, then pin the lock file
Deadbugz arrived as pull requests, so the cheapest control sits at review time. Treat any change that adds an MCP server or remote endpoint as a security-relevant event, which is CSA's wording. The workflow below fails a pull request that touches common MCP config locations and separately searches added lines for the two indicators Pillar published. The config file names are the usual client locations, so extend the pattern for the clients you run. The lock file is the other half: it records an approved digest for every tool and for each approved prompt.
# .github/workflows/mcp-change-gate.yml
name: mcp-change-gate
on: pull_request
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the diff below needs the base branch history
- name: Block unreviewed MCP wiring
run: |
# Common client config locations. Extend for the clients your team uses.
CONFIGS='(^|/)(\.mcp\.json|mcp\.json|\.cursor/mcp\.json|\.vscode/mcp\.json)$'
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E "$CONFIGS"; then
echo "::error::MCP config changed. Needs a security review label."
exit 1
fi
- name: Hunt the published Deadbugz indicators
run: |
# Indicators named in Pillar Security's write-up. Added lines only.
if git diff origin/${{ github.base_ref }}...HEAD | grep -E '^\+' \
| grep -E 'productivity-suite|\.deadbug-mcp\.py'; then
echo "::error::Deadbugz indicator found in this pull request."
exit 1
fi// mcp.lock.json (committed; changing it is a reviewed, owned change)
{
"servers": {
"internal-docs": {
"approvedBy": "security-review-2026-10",
"protocolRevision": "2025-06-18",
"tools": {
"search_docs": "sha256:<digest of the full tools/list entry>",
"get_page": "sha256:<digest of the full tools/list entry>"
},
"prompts": {
"summarise_page": "sha256:<digest of prompts/get for the approved arguments>"
}
}
}
}Treat the lock file like a dependency lockfile. A change to a digest is a reviewed change with an owner, and the person who approves it should be able to read the diff of the actual definition, not just two hashes. Store the full approved definition next to the digest, or the review of a drift alert becomes a guess.
Pillar says to preserve MCP client logs before cleanup and review tool-definition refreshes and agent actions after the third call. That only works if you already log definitions at session start. Write the fingerprint, the server name and the session id into your audit log on every connect, so the evidence exists before you need it.
Where fingerprinting fails, and what it costs
I want the limits on the table, because a control sold as complete is how the next incident gets through. These are my own reading of the mechanism, not findings from the sources, which describe the attack and the defences but not how any particular fingerprinting tool behaved against it.
- It only detects change. If the definition you approved was already hostile, the hash faithfully protects the hostile text. The review of the first definition still has to be real.
- A server can know who is asking. CSA says the counter is kept per connected client, so a scanner on its own connection may see a different server than your agent does. Run the drift check inside the agent's session, not beside it.
- Legitimate servers change too. A vendor that edits a description produces the same alert as an attacker, so expect re-approval churn and budget a reviewer for it.
- Tool results are not metadata. Text returned from a tools/call is outside the fingerprint, and the spec asks servers to sanitise outputs and clients to validate results before they reach the model.
For that reason Pillar and OWASP both pair detection with a second layer that does not depend on reading text: keep file reads, credential access, code execution, email and repository writes under policy rather than letting tool metadata trigger them. Fingerprinting tells you the server changed. The deny rule is what limits the damage when you are late.
Layering the controls against this one campaign
No single control covers the whole attack, so the table lays them against what each one can see. The last column is my reading of how each would have fared against the behaviour the three write-ups describe. It is a judgement, not a test result.
| Control | What it can see | Against Deadbugz (my reading) |
|---|---|---|
| One-time review at install | The README, the code and the first responses | Passes it, because the first three calls are normal |
| Static review of the repository | The initial code path | Likely finds nothing, per CSA, since the instructions stay out of the path until the counter condition is met |
| Short automated scan | Responses to a few calls in a fresh session | Stays under the threshold if it makes fewer than four tool calls |
| Fingerprint at init plus re-check after each call | Every tools/list response during the session | Should flag the change at the first listing after the third call, if it runs in the agent's own session |
| Deny by default on credential stores | What the agent tries to read, not what the server says | Blocks the stated goal even when the metadata change is missed. This is Pillar's and OWASP's second layer |
| Internal registry plus PR gate | Which servers enter the codebase at all | Stops the delivery route, which was unmerged pull requests, before a server is ever configured |
Read down the last column and the pattern is clear. The first three controls all look at the server before it misbehaves, and the campaign was built to be quiet exactly then. The useful controls act at run time or at the door.
What to do on Monday
Five steps, in the order I would do them. None needs new tooling beyond the script above.
- Search your repositories, home directories and MCP client configs for productivity-suite and the hidden script name from Pillar's indicator list, and keep client logs before you clean anything up.
- List every MCP server your team has configured, who approved it and when, and record a fingerprint of each tool and approved prompt. Servers nobody can name an approver for go on a removal list.
- Add the pull request gate so that any change to MCP config needs a named security reviewer.
- Move the drift check into the agent session so it runs after each tools/call, and make a mismatch stop the session and require re-approval.
- Deny agent reads of SSH keys, cloud credential files, shell history and kube configs by default, and open exceptions one at a time. If exposure is confirmed or cannot be ruled out, rotate the credentials that were reachable.
CSA's strategic list goes further: bring MCP manifests into software composition analysis, keep an internal registry of approved servers, and red-team for delayed or conditional activation. Test your own pipeline with a harmless server that changes a description on call four and see whether anything notices.
The rule I carry out of Deadbugz is that an MCP approval is a statement about a moment, not about a server. Approve a definition, fingerprint it, and treat every later difference as a security event until a person has read it. Nobody merged these pull requests, which is the only reason this is a warning rather than a post-mortem.
Sources and further reading
- Pillar Security: Deadbugz, a currently active MCP supply chain campaign
- Cloud Security Alliance research note: Deadbugz and the MCP supply chain
- OWASP GenAI: Agentic AI exploit roundup Q3 2026
- Model Context Protocol specification 2025-06-18: Tools
- Model Context Protocol specification 2025-06-18: Prompts
- Invariant Labs: MCP security notification on tool poisoning attacks

