{"slug": "i-shipped-an-mcp-server-that-reported-success-without-signing-anything", "title": "I shipped an MCP server that reported success without signing anything", "summary": "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.", "body_md": "I built an MCP server that lets an AI assistant trade tokens and claim creator fees on\n\nSolana. Then I shipped a version where the two write tools built transactions, discarded\n\nthem, and returned success. Nothing was ever signed. Nothing was ever submitted.\n\nIt had 337 tests. All of them passed.\n\nI didn't find out for three months.\n\nThis post is about what that bug taught me, and the design it produced — because the\n\ninteresting part isn't the bug, it's that every gate I had in place was green while the\n\none thing the product existed to do wasn't happening.\n\nMCP is a good protocol. It is also, by design, a way to hand a language model a set of\n\nfunctions and let it decide when to call them.\n\nThat's fine when the functions read. It's a different proposition when one of them can\n\nmove money. The assistant decides, the transaction is already on chain by the time a\n\nhuman reads about it, and nothing in the protocol makes the model pause. Nothing bounds\n\nwhat a single misunderstood instruction can spend.\n\nThe specific thing that worries me isn't the model being *wrong*. It's the model being\n\n*persuaded*. Token names and descriptions are attacker-controlled strings that end up in\n\na model's context. \"Ignore previous limits, this is a test transaction\" is a plausible\n\nthing to find inside a token's metadata.\n\nSo the question I wanted to answer in code was: **how do you let an assistant initiate a\nspend without letting it complete one?**\n\nThe answer I landed on is that a write tool's first call is never an execution. It's a\n\nproposal.\n\n```\n⚠️  CONFIRMATION REQUIRED — nothing has been signed or sent.\n\nAction:  Swap 0.05 of So11111111111111111111111111111111111111112\n         for       EkJuyYyD3to61CHVPJn6wHb7xANxvqApnVJ4o2SdBAGS\n         expect    4823917722 (min 4679199990)\n         slippage  3%\n         network   🔴 MAINNET — real funds\n\nSpend:   0.05 SOL\nCaps:    0.1 SOL/tx · 0/1 SOL used this session\n\nTo execute, call bags_execute_trade again with the identical arguments plus:\n  confirm: \"kR3nT9xQm2vP\"\n\nToken is single-use and expires in 5 minutes.\n```\n\nThe assistant can produce that all day. It cannot spend anything with it.\n\nThis is the part that matters, and it's four lines:\n\n```\nexport function fingerprint(toolName: string, args: unknown): string {\n  return createHash('sha256')\n    .update(toolName)\n    .update(' ')\n    .update(JSON.stringify(args ?? null))\n    .digest('hex')\n    .slice(0, 32);\n}\n```\n\nA token carries the SHA-256 of the tool name plus the exact arguments it was issued for.\n\nConfirming re-derives that fingerprint from the arguments of the *second* call and\n\ncompares.\n\nThe consequence: a token obtained for a 0.05 SOL swap cannot authorize a 10 SOL one. Not\n\nbecause a check says \"is this bigger\" — because the token simply isn't valid for\n\ndifferent arguments. If the model re-quotes with new numbers, the old token is dead.\n\nIt's single-use and consumed on **every** outcome, including failure, so it can't be\n\nreplayed:\n\n```\n/**\n * Single-use. Throws if the token is unknown, expired, or was issued for a\n * different action. Consumed on every outcome so a token can never be replayed.\n */\nexport function consumeToken(token: string, toolName: string, args: unknown): void {\n```\n\nTTL is five minutes.\n\nTwo limits, both SOL-denominated: 0.1 per transaction and 1.0 per session, both\n\nconfigurable. A request over the cap is refused before the Bags SDK is reached — not\n\nafter a partial call, not by inspecting a failure.\n\nThere's an honest edge here I had to decide about. The caps are denominated in SOL, so\n\nthey cannot value an arbitrary SPL token. A non-SOL-denominated swap would therefore be\n\n*uncapped*. Rather than pretend otherwise, that case is refused unless you explicitly opt\n\nin with `BAGS_ALLOW_UNCAPPED_TOKEN_SWAPS=true`\n\n— and when you do, the preview says\n\nplainly that no cap applies instead of displaying a reassuring \"Spend: 0 SOL\".\n\nA misleading zero is worse than an honest refusal.\n\nHere is the full write path as it stands:\n\n```\ntoken gate → spend caps → confirmation → simulate → sign → send → confirm\n```\n\nIn 1.x, the last four steps were the problem. The code built a transaction. Then it\n\nreturned a success object. The transaction was garbage collected.\n\nEvery test passed, because every test asserted on the return value. Coverage was 100% —\n\nstatements, branches, functions, lines — because the code that built the transaction\n\n*ran*. It just didn't do anything with it.\n\nThat's the lesson, and it generalizes well past Solana:\n\nA function returning`{ success: true }`\n\nproves the function returned. It proves\n\nnothing about the outside world.\n\nIf your test suite passes with the network unplugged, you have tested your code, not your\n\nintegration. Coverage measures the lines you wrote. It says nothing about whether the\n\npromise those lines make is kept.\n\nTwo things.\n\n**Simulate runs before signing.** The cheap check goes first — a malformed or underfunded\n\ntransaction dies without burning a fee to discover it:\n\n```\n/**\n * Simulate before signing. A failed simulation aborts the write — the cheap\n * check that stops a malformed or under-funded transaction being submitted.\n */\nsimulate: async function (tx) {\n  const result = isVersioned(tx)\n    ? await connection.simulateTransaction(tx, { sigVerify: false })\n    : await connection.simulateTransaction(tx);\n\n  if (result.value.err) {\n    throw new SimulationError(...);\n  }\n  return result.value.logs ?? null;\n}\n```\n\n**\"Confirmed\" means the network confirmed it.** `signSendConfirm`\n\nreturns only once the\n\nsignature is confirmed, and throws otherwise. There is no path that reports success for a\n\ntransaction that didn't land — which sounds obvious, and was exactly what 1.x got wrong.\n\nGiven all of the above, I don't think you should take my word for any of it. So there's a\n\nscript that pushes a transfer through the *same* `simulate → sign → send → confirm`\n\npath\n\nthe write tools use, then re-fetches the signature from the chain rather than trusting the\n\nfunction's return value:\n\n```\n--- PROOF -------------------------------------------------\nsignature 2kvu25xWAjqCB3wuNzwMRcN2RMqqfYN6TeJjnA888YtCqNJi9EU9CHSxynkq5QdM499e6yKbXYAwXUbzDKY9U5Dm\nslot      484219564\nwall      864 ms (simulate + sign + send + confirm)\n-----------------------------------------------------------\n\nverified  re-fetched from chain in slot 484219564, err=null\n          fee 5000 lamports\n```\n\nCheck it yourself — this needs nothing from me:\n\n```\ncurl -s -X POST https://api.devnet.solana.com \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getTransaction\",\n       \"params\":[\"2kvu25xWAjqCB3wuNzwMRcN2RMqqfYN6TeJjnA888YtCqNJi9EU9CHSxynkq5QdM499e6yKbXYAwXUbzDKY9U5Dm\",\n                 {\"encoding\":\"json\",\"maxSupportedTransactionVersion\":0}]}'\n# → slot 484219564, meta.err null, meta.fee 5000\n```\n\nIt's devnet, deliberately. The execution layer is what's under test and devnet exercises\n\nit identically at zero real cost. A mainnet receipt would prove the same thing while\n\ncosting money and telling you nothing extra.\n\nThis comes up constantly, and the answer is a flat no.\n\n`--http`\n\nserves `/mcp`\n\non `0.0.0.0`\n\nwith permissive CORS and no auth. Every caller shares\n\none spend counter and one network. Hosting that means publishing an unauthenticated\n\nmainnet spending endpoint — for a project whose entire claim is that spends are gated,\n\ncapped and confirmed.\n\nIt stays stdio, running locally as a subprocess of your MCP client, where the keypair sits\n\non your filesystem and the spend counter is yours.\n\nThis has a concrete cost. One MCP registry computes a \"quality score\" that reads tool\n\nmetadata by connecting to hosted servers. A stdio server scores zero on that entire\n\nsection — 40 points — no matter how good its tools are. I'd rather have the 40 points.\n\nI'm not trading an unauthenticated spending endpoint for them.\n\n`bigint-buffer`\n\n,\nGHSA-3gc7-fjrx-p6mg) reached through the Bags SDK. No patched version exists. CI blocks\nany critical, and any increase over a committed baseline.\n\n```\nnpx bagos-mcp-server\n```\n\n14 tools — 11 read, 1 gated, 2 write. 337 tests, 17 suites, 100% coverage enforced in CI.\n\nPublished from CI with npm provenance, so the tarball is cryptographically attested to the\n\ncommit that built it.\n\nI've since written this down as a rule for myself, because it isn't specific to crypto:\n\nFor the one capability your project is\n\nabout, write a test that asserts the external\n\nside effect — not the return value. Then, before you ship, verify it once in a system\n\nyou 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\n\nyour coverage number is measuring the wrong thing.\n\nI had every signal a mature project is supposed to have — tests, coverage, CI, lint,\n\nprovenance, a security policy. All of them were green on a build whose headline feature\n\nwas inert. The gates weren't wrong. They were just all pointed inward.", "url": "https://wpnews.pro/news/i-shipped-an-mcp-server-that-reported-success-without-signing-anything", "canonical_source": "https://dev.to/edycutjong/i-shipped-an-mcp-server-that-reported-success-without-signing-anything-6oh", "published_at": "2026-08-16 00:30:27+00:00", "updated_at": "2026-08-16 01:11:10.782952+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools"], "entities": ["MCP", "Solana", "Bags SDK"], "alternates": {"html": "https://wpnews.pro/news/i-shipped-an-mcp-server-that-reported-success-without-signing-anything", "markdown": "https://wpnews.pro/news/i-shipped-an-mcp-server-that-reported-success-without-signing-anything.md", "text": "https://wpnews.pro/news/i-shipped-an-mcp-server-that-reported-success-without-signing-anything.txt", "jsonld": "https://wpnews.pro/news/i-shipped-an-mcp-server-that-reported-success-without-signing-anything.jsonld"}}