I shipped an MCP server that reported success without signing anything A developer shipped an MCP server for Solana token trading that silently failed to sign or submit transactions for three months, despite 337 passing tests. The bug led to a redesign where write tools return a proposal requiring a single-use, fingerprint-bound confirmation token before execution, preventing unauthorized spends. I built an MCP server that lets an AI assistant trade tokens and claim creator fees on Solana. Then I shipped a version where the two write tools built transactions, discarded them, and returned success. Nothing was ever signed. Nothing was ever submitted. It had 337 tests. All of them passed. I didn't find out for three months. This post is about what that bug taught me, and the design it produced โ€” because the interesting part isn't the bug, it's that every gate I had in place was green while the one thing the product existed to do wasn't happening. MCP is a good protocol. It is also, by design, a way to hand a language model a set of functions and let it decide when to call them. That's fine when the functions read. It's a different proposition when one of them can move money. The assistant decides, the transaction is already on chain by the time a human reads about it, and nothing in the protocol makes the model pause. Nothing bounds what a single misunderstood instruction can spend. The specific thing that worries me isn't the model being wrong . It's the model being persuaded . Token names and descriptions are attacker-controlled strings that end up in a model's context. "Ignore previous limits, this is a test transaction" is a plausible thing to find inside a token's metadata. So the question I wanted to answer in code was: how do you let an assistant initiate a spend without letting it complete one? The answer I landed on is that a write tool's first call is never an execution. It's a proposal. โš ๏ธ CONFIRMATION REQUIRED โ€” nothing has been signed or sent. Action: Swap 0.05 of So11111111111111111111111111111111111111112 for EkJuyYyD3to61CHVPJn6wHb7xANxvqApnVJ4o2SdBAGS expect 4823917722 min 4679199990 slippage 3% network ๐Ÿ”ด MAINNET โ€” real funds Spend: 0.05 SOL Caps: 0.1 SOL/tx ยท 0/1 SOL used this session To execute, call bags execute trade again with the identical arguments plus: confirm: "kR3nT9xQm2vP" Token is single-use and expires in 5 minutes. The assistant can produce that all day. It cannot spend anything with it. This is the part that matters, and it's four lines: export function fingerprint toolName: string, args: unknown : string { return createHash 'sha256' .update toolName .update ' ' .update JSON.stringify args ?? null .digest 'hex' .slice 0, 32 ; } A token carries the SHA-256 of the tool name plus the exact arguments it was issued for. Confirming re-derives that fingerprint from the arguments of the second call and compares. The consequence: a token obtained for a 0.05 SOL swap cannot authorize a 10 SOL one. Not because a check says "is this bigger" โ€” because the token simply isn't valid for different arguments. If the model re-quotes with new numbers, the old token is dead. It's single-use and consumed on every outcome, including failure, so it can't be replayed: / Single-use. Throws if the token is unknown, expired, or was issued for a different action. Consumed on every outcome so a token can never be replayed. / export function consumeToken token: string, toolName: string, args: unknown : void { TTL is five minutes. Two limits, both SOL-denominated: 0.1 per transaction and 1.0 per session, both configurable. A request over the cap is refused before the Bags SDK is reached โ€” not after a partial call, not by inspecting a failure. There's an honest edge here I had to decide about. The caps are denominated in SOL, so they cannot value an arbitrary SPL token. A non-SOL-denominated swap would therefore be uncapped . Rather than pretend otherwise, that case is refused unless you explicitly opt in with BAGS ALLOW UNCAPPED TOKEN SWAPS=true โ€” and when you do, the preview says plainly that no cap applies instead of displaying a reassuring "Spend: 0 SOL". A misleading zero is worse than an honest refusal. Here is the full write path as it stands: token gate โ†’ spend caps โ†’ confirmation โ†’ simulate โ†’ sign โ†’ send โ†’ confirm In 1.x, the last four steps were the problem. The code built a transaction. Then it returned a success object. The transaction was garbage collected. Every test passed, because every test asserted on the return value. Coverage was 100% โ€” statements, branches, functions, lines โ€” because the code that built the transaction ran . It just didn't do anything with it. That's the lesson, and it generalizes well past Solana: A function returning { success: true } proves the function returned. It proves nothing about the outside world. If your test suite passes with the network unplugged, you have tested your code, not your integration. Coverage measures the lines you wrote. It says nothing about whether the promise those lines make is kept. Two things. Simulate runs before signing. The cheap check goes first โ€” a malformed or underfunded transaction dies without burning a fee to discover it: / Simulate before signing. A failed simulation aborts the write โ€” the cheap check that stops a malformed or under-funded transaction being submitted. / simulate: async function tx { const result = isVersioned tx ? await connection.simulateTransaction tx, { sigVerify: false } : await connection.simulateTransaction tx ; if result.value.err { throw new SimulationError ... ; } return result.value.logs ?? null; } "Confirmed" means the network confirmed it. signSendConfirm returns only once the signature is confirmed, and throws otherwise. There is no path that reports success for a transaction that didn't land โ€” which sounds obvious, and was exactly what 1.x got wrong. Given all of the above, I don't think you should take my word for any of it. So there's a script that pushes a transfer through the same simulate โ†’ sign โ†’ send โ†’ confirm path the write tools use, then re-fetches the signature from the chain rather than trusting the function's return value: --- PROOF ------------------------------------------------- signature 2kvu25xWAjqCB3wuNzwMRcN2RMqqfYN6TeJjnA888YtCqNJi9EU9CHSxynkq5QdM499e6yKbXYAwXUbzDKY9U5Dm slot 484219564 wall 864 ms simulate + sign + send + confirm ----------------------------------------------------------- verified re-fetched from chain in slot 484219564, err=null fee 5000 lamports Check it yourself โ€” this needs nothing from me: curl -s -X POST https://api.devnet.solana.com \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"getTransaction", "params": "2kvu25xWAjqCB3wuNzwMRcN2RMqqfYN6TeJjnA888YtCqNJi9EU9CHSxynkq5QdM499e6yKbXYAwXUbzDKY9U5Dm", {"encoding":"json","maxSupportedTransactionVersion":0} }' โ†’ slot 484219564, meta.err null, meta.fee 5000 It's devnet, deliberately. The execution layer is what's under test and devnet exercises it identically at zero real cost. A mainnet receipt would prove the same thing while costing money and telling you nothing extra. This comes up constantly, and the answer is a flat no. --http serves /mcp on 0.0.0.0 with permissive CORS and no auth. Every caller shares one spend counter and one network. Hosting that means publishing an unauthenticated mainnet spending endpoint โ€” for a project whose entire claim is that spends are gated, capped and confirmed. It stays stdio, running locally as a subprocess of your MCP client, where the keypair sits on your filesystem and the spend counter is yours. This has a concrete cost. One MCP registry computes a "quality score" that reads tool metadata by connecting to hosted servers. A stdio server scores zero on that entire section โ€” 40 points โ€” no matter how good its tools are. I'd rather have the 40 points. I'm not trading an unauthenticated spending endpoint for them. bigint-buffer , GHSA-3gc7-fjrx-p6mg reached through the Bags SDK. No patched version exists. CI blocks any critical, and any increase over a committed baseline. npx bagos-mcp-server 14 tools โ€” 11 read, 1 gated, 2 write. 337 tests, 17 suites, 100% coverage enforced in CI. Published from CI with npm provenance, so the tarball is cryptographically attested to the commit that built it. I've since written this down as a rule for myself, because it isn't specific to crypto: For the one capability your project is about, write a test that asserts the external side effect โ€” not the return value. Then, before you ship, verify it once in a system you don't control. A block explorer. A database you read back. An inbox.If every test still passes with the network unplugged, the capability is untested, and your coverage number is measuring the wrong thing. I had every signal a mature project is supposed to have โ€” tests, coverage, CI, lint, provenance, a security policy. All of them were green on a build whose headline feature was inert. The gates weren't wrong. They were just all pointed inward.