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.
The problem with giving a model a signing key
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 design: the first call signs nothing
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.
The token is bound to the arguments, not just to the session
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.
Caps are checked before the SDK is called
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.
Now the bug
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.
What changed
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.
The receipt
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.
Why I won't host it
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.
Honest limitations
- Writes are mainnet-only. Bags has no devnet deployment; its API and Meteora fee-share program IDs are mainnet. The server still defaults to devnet, so an unconfigured install cannot spend real money, and calling a write tool on devnet returns an explanation rather than a cryptic program error.
- The confirmation token is in-memory. Restarting the server clears pending confirmations. That fails in the safe direction, but it is a real limitation.
-
6 high advisories, all one transitive root cause (
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. - 1.x is deprecated on npm with a pointer to the defect it carried. If you installed it and believed a trade executed, it did not.
Try it
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.
The takeaway I'd actually keep
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.
Top comments (0)