Skip to content
Original
DEV Community · MCP· Gautam K·· 22 hours agoAI score71

mcp-pin 作者复盘:安全工具报成功 100 次却只留下 12 条记录

Original title: The bug a security tool must never have

The title and summary in the selected language are awaiting translation.

AI overview

作者为自己 9 月发布的 MCP 工具 mcp-pin 复盘了一个并发丢写 bug:同时 pin 100 个服务器时全部报成功,磁盘上 pins.json 却只有 12 个 key,88 条审批记录丢失,日志哈希链也断了 5 次。

Full text

My security tool said SUCCESS 100 times. It kept 12.

That's not a figure of speech. It's the first entry in the changelog of mcp-pin, the tool I built and released in September, and it's the reason the changelog entry for version 0.1.0 ends with one line: Do not use it.

This post is about that bug: what happened, why it's the worst possible failure for this kind of software, and what I changed so it can't come back quietly.

If you'd rather watch than read, here's the 7 minute film about this bug and the problem mcp-pin exists for:

What mcp-pin is for

AI agents use tools through MCP servers. Each tool comes with a description, and the model reads that description as instructions. You approve a tool once. Your agent re-reads its description at the start of every session, and nothing tells you if it changed in between.

mcp-pin is a small proxy that sits between your client and a server. The first time it sees a server, it fingerprints every tool (name, description, input schema, annotations) and pins that fingerprint. Every session after that, it checks. If anything changed, the session is blocked and you get a diff.

So the whole product is one promise: I will remember what you approved.

The test

I ran one hundred pins at the same time, for one hundred different servers. Every single one reported success.

Then I looked at what was actually on disk:

  • log.ndjson had 100 entries with 100 unique server ids.
  • pins.json had 12 keys.

Eighty-eight approvals were gone. The hash chain in the log also broke five times, because log appends had the same problem.

Why it happened

0.1.0 kept every pin in one file, and updated it like this (simplified):

const pins = JSON.parse(fs.readFileSync(PINS_FILE, 'utf8'));
pins[serverId] = record;
fs.writeFileSync(PINS_FILE, JSON.stringify(pins));

Read the file, add your key, write the file back. With one process that's fine. With two processes at the same moment, both read the same old file, both add their own key, and the second write erases the first. That's a lost update, one of the oldest bugs there is. With a hundred processes, most of them lose.

Nothing threw an error. Each process wrote its own version of the file successfully. Each one printed that it had pinned the server. Each one was telling the truth about its own write and wrong about the outcome.

Why this is the worst bug a tool like this can have

A tool that crashes makes you go and look. A tool that fails loudly gets fixed.

A tool that reports success while dropping the thing it exists to keep does the opposite. It makes you stop looking. You were careful, you put a pin in front of your most dangerous server, and the tool told you it was handled.

Here's what a lost pin means in practice. To the proxy, a server with no pin looks like a first run, and a first run pins whatever the server says that day. So if the server changed its tool after you approved it, which is the exact attack mcp-pin exists to catch, the next session would quietly accept the new version as the baseline. No block. No diff.

A version of it that loses approvals while telling you everything is fine is worse than not having it, because you'd trust it. That is the worst possible failure mode for this category of software, and I shipped it in the first version.

The irony isn't lost on me. mcp-pin catches a change that happens with no signal. This bug was a change that happened with no signal.

What I changed

The fixes are in 0.1.1 and 0.1.2. None of them are clever, which is the point.

One file per server, replaced atomically. Each pin now lives in its own file, pins.d/<server_id>.json. It's written to a temp file, fsynced, and renamed into place. Two different servers can't touch the same file anymore, and a reader never sees half a write.

The log takes a lock. Appending to the log means taking an exclusive lock, reading the tail, appending, fsyncing, and unlocking. Two processes can't extend the hash chain at the same time.

Windows contends differently. The lock is a file opened with the exclusive create flag. On Linux, a held lock shows up as EEXIST. On Windows it can also be EPERM, EACCES or EBUSY, and 0.1.1 only expected the first. That shipped in a tagged release, which I withdrew before it reached npm; 0.1.2 handles all four.

The record comes first. The first pin used to write the pin file and then the log entry. If the append failed, you had a trusted pin with no public record of it. The test matrix caught it on Windows with Node 20: 16 pin files, 15 log lines. Now the log entry is the commitment, and the pin is written only after it succeeds.

Corruption fails closed. 0.1.0 treated a truncated pins.json or a garbage log as "no pins yet". For a pinning tool that's the same lost-pin failure through a different door. Now corrupt state is a hard error that names the file. A missing file is still allowed, because that's a genuine first run.

The client waits until the check is done. 0.1.0 forwarded the client's first message straight away and checked the tools in parallel, so a fast client could call a tool before the block arrived. Now client messages are queued from the start, the client gets no answer until the tools match the pin, and on drift the queue is thrown away.

What I'd tell anyone building a security tool

Test the concurrent path on day one. The bug only existed when two writes overlapped, and real setups start several servers at once.

Only claim success after the durable write. "Pinned" should mean the bytes are fsynced and the record exists, not that a function returned.

Fail closed when you can't read your own state. If a security tool doesn't know what you approved, it should stop, not assume.

Write your failures down where users will see them. The changelog for 0.1.0 doesn't soften it. If you're trusting a tool with your approvals, you should know how it has failed before.

See it work

The whole failure the tool exists for, in about ten seconds, with a harmless bundled server and nothing to configure:

npx [email protected] demo

The first session pins a tool. The second session, the tool's description starts asking for notes from your conversation, and mcp-pin blocks it with the diff.

The code, the changelog and the public log of tool changes are at github.com/GautamTalksDev/mcp-pin. The film above covers the problem and both bugs I shipped while building it.

If you run it and something looks wrong, open an issue. I'd much rather hear about it loudly.

Source: DEV Community · MCP · dev.to