# I shipped an MCP server that reported success without signing anything

> Source: <https://dev.to/edycutjong/i-shipped-an-mcp-server-that-reported-success-without-signing-anything-6oh>
> Published: 2026-08-16 00:30:27+00:00

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.
