Skip to main content
Glama
ketankshukla

learn-mcp-agent-ledger

by ketankshukla

πŸ’Έ The Host That Owns The Wallet

Five projects into a series named after the protocol, every MCP server we built was a vending machine. You put a request in, a result comes out, and it costs you exactly what you put in.

This project uses the capability that turns that inside out. Sampling is the server asking your host to run a model call. It has no API key. It has no model. It has yours.

πŸ”΄ Live app: https://learn-mcp-agent-ledger.vercel.app πŸ”Œ Live MCP endpoints: /api/kitchen (the one that asks), /api/pantry, /api/jar β€” all open, deliberately πŸͺ #1 the server Β· #2 the loop Β· #3 the gate Β· #4 the crew

The whole idea is two files: lib/spend-gate.ts decides, lib/sampling.ts does.

πŸ”¨ Want to build it yourself, from an empty folder? BUILD_FROM_SCRATCH.md is the developer walkthrough β€” every command, a checkpoint proving each stage, and an appendix of the eleven things that actually broke while this was being built. This README teaches you what sampling is and why it needs a gate; that one teaches you how to build it.


The headline, before anything else

Your own server asked your own host to spend your money, and your host said no:

πŸ›‘ The Kitchen β†’ summarise_week  [claude-sonnet-5]  6.0Β’ estimated β€” REFUSED, $0.00 spent

  tokens on the tab   : 0
  refused (not spent) : 6.0Β’  ← what the ceiling saved you

model calls servers asked for

13

allowed

8 Β· 13.0Β’ actually spent

refused

5 Β· 24.2Β’ estimated, $0.00 actually spent

regression suite

100% β€” project #3's six, #4's four, #5's three

cost of re-running that suite

$0.00, from stored traces

Two things worth saying immediately.

A refusal costs nothing. The gate reasons about an estimate, before the call, so refusing spends no tokens at all. Project #3's gate saved you from a destroyed cookie jar; this one saves you from the bill itself. That is not a nice detail β€” it is the argument for gating on price rather than on which tool was called.

And the naive implementation of this feature does not work. Every tutorial describes sampling as the server pushing a request down an open connection. On a serverless host there is no connection, and the SDK says so in as many words. What actually works is a retry. That finding, and how we got it, is part 2.


Related MCP server: sampling-mcp

Table of contents

Part

What it covers

1

Four capabilities we never used

2

The arrow that points back

3

A budget is not a gate

4

The gate that cannot pause

5

One wallet, many spenders

6

The regression suite that costs nothing

7

Data does not stay yours

8

Run it yourself

9

Where to go next


Part 1 β€” Four capabilities we never used

Here is the whole menu, and how much of it four projects had touched:

Capability

What it is

Used in #1–#4

tools

the server does something

βœ… all four projects

resources

the server offers something to read

❌ never

prompts

the server offers a recipe for asking

❌ never

sampling

the server asks your host to run a model call

❌ never

elicitation

the server asks your user a question

❌ never

The honest summary of the first three missing ones: they are the same shape as the one you already know. You ask, the server answers. resources/read is tools/call with a URI instead of arguments. prompts/get returns some messages. Both were an afternoon.

There is one distinction inside that afternoon which is worth keeping, because it is easy to blur:

A tool is something the model decides to invoke. A resource is something the host decides to show it.

Reading a resource is not an action the agent takes; it is context somebody put in front of it. That makes resources the boring capability β€” and the one you would reach for most often in anything real, because most of what an agent needs is not an action.

The same care applies to prompts, and it is a security point rather than a taste one. A server-supplied prompt is an offer. This host fetches it and hands it back as text for a human to look at; it does not splice it into the model's conversation. A host that piped server-supplied prompts directly into its own model would have built a prompt-injection channel with good manners.


Part 2 β€” The arrow that points back

Sampling is the one that is genuinely different, and here is the picture:

sequenceDiagram
    autonumber
    participant U as πŸ§‘ you
    participant H as πŸŽ›οΈ your host
    participant K as 🧰 the kitchen server
    participant M as 🧠 the model

    U->>H: "summarise the pantry week"
    H->>K: tools/call summarise_week

    rect rgba(56, 189, 248, 0.12)
        note over K: the server CAN read the event log…
        K->>K: select * from pantry_events
        note over K: …but it has no model and no API key
    end

    rect rgba(251, 191, 36, 0.16)
        K-->>H: input_required β€”<br/>"run this model call for me"
        note over H,K: THE ARROW REVERSES.<br/>the server is now the one asking.
    end

    rect rgba(239, 68, 68, 0.14)
        note over H: πŸ’° the spend gate decides FIRST
        H->>H: estimate the cost Β· check the ceiling
    end

    H->>M: your key. your bill.
    M-->>H: the summary
    H->>K: tools/call summarise_week<br/>+ inputResponses + requestState
    K-->>H: the finished digest
    H-->>U: "here's what happened in the pantry"

The part that surprised us

The obvious way to write that server is one line:

const answer = await server.server.createMessage({ ... });   // ❌

It is what every tutorial shows. It throws here, and the SDK explains why itself:

"Cannot request input (sampling/createMessage): the client on this 2025-era connection did not declare the required capability β€” per-request legacy serving cannot receive server-to-client requests"

The reason is structural rather than a bug. A server→client push needs a connection to travel down. A serverless function does not have one; it has a request, and then it doesn't. There is nowhere for the push to go.

So the 2026-07-28 protocol revision removed the server→client channel entirely and replaced it with a retry:

round 1   β†’  tools/call
          ←  {"resultType":"input_required",
              "inputRequests":{"summary":{"method":"sampling/createMessage",…}},
              "requestState":"…"}
          Β·  the host runs the model
round 2   β†’  tools/call  + params.inputResponses + params.requestState
          ←  {"resultType":"complete","content":[…]}

A retry needs no connection at all β€” which is exactly why they changed it. The protocol grew a stateless shape because everything is serverless now.

We found this by writing a throwaway server and curling it, not by remembering how sampling works. The full trail is stage 2 of BUILD_FROM_SCRATCH.md, and it is the fifth consecutive project where reading the types on disk changed the design.

One consequence worth knowing before you copy lib/mcp-client.ts: this danger is opt-in. The host declares capabilities: { sampling: {} } on every request. Delete that one line and every sampling request from every server fails with -32021. The safest configuration is one character shorter than the useful one.


Part 3 β€” A budget is not a gate

The tempting design is a budget: give the run a spending limit, subtract as you go, stop at zero.

It is worse than it looks, for a reason that has nothing to do with arithmetic. A budget that runs out silently truncates whatever was happening at the moment it ran out. You do not get a decision; you get an arbitrary cut-off point determined by the order things happened in.

The other tempting design is project #3's: gate on what the call is. That worked for destruction because destruction is a property of the verb β€” smash_jar always asks, jar_history never does.

Cost is not a property of the verb, and this project can prove it with one tool:

the same tool, the same server, the same user question

estimate

summarise_week { depth: "brief" } β€” reads the event log, small model

0.22Β’

summarise_week { depth: "deep" } β€” every jar's full report, bigger model

6.0Β’

Twenty-seven times the price, same name. A rule keyed to summarise_week would have to stop both or allow both, and neither is what you want.

So the rule is a number:

below the ceiling   β†’  runs immediately, logged to the ledger
above the ceiling   β†’  refused, with the price attached

And gating on the estimate rather than the result is what makes a refusal free. The host never contacts the model, so the most expensive thing a server can ask for costs exactly as much as the cheapest thing it can ask for: nothing.


Part 4 β€” The gate that cannot pause

This is the part that took longest to get right, and the answer is a genuine limitation rather than a clever trick.

Project #3's gate pauses. The loop stops, the HTTP request ends, the entire agent is written to Postgres, you close your laptop, and a brand-new request thaws it out five minutes later. That works because the thing being frozen is ours. Our agent does not mind waiting.

A sampling request is not ours. Somebody else's server is sitting inside a tools/call waiting for an answer, and it will time out in under a minute. Freezing it does not pause it β€” it deadlocks it.

flowchart TB
    START(["🧰 kitchen asks for a model call<br/><b>estimated 6.0Β’</b>"]) --> GATE{"πŸ’° over the<br/>ceiling?"}

    GATE -->|"no β€” 0.22Β’ ≀ 2Β’"| RUN["🧠 run the model<br/><i>on the host's key</i>"]
    RUN --> TAB["🏦 add to the shared TreeBudget<br/>πŸ““ write a ledger row<br/><i>tagged with WHO asked</i>"]
    TAB --> BACK(["βœ… answer returned to the server"])

    GATE -->|"yes β€” 6.0Β’ > 2Β’"| NO["πŸ›‘ REFUSE, synchronously<br/><b>$0.00 spent</b><br/><i>no model was contacted</i>"]
    NO --> TELL["πŸ““ write a ledger row anyway<br/><i>a refusal that leaves no trace is<br/>indistinguishable from a gate<br/>that is switched off</i>"]
    TELL --> CARD["πŸ§‘ a card appears for the human"]
    CARD --> GRANT{"grant a<br/>one-shot<br/>allowance?"}
    GRANT -->|"yes"| NEXT(["⏭ spent by the agent's NEXT attempt"])
    GRANT -->|"no"| DONE(["🚫 the server is told, plainly,<br/>what it may do instead"])

    style START fill:#1e293b,stroke:#38bdf8,color:#f8fafc
    style GATE fill:#78350f,stroke:#fbbf24,stroke-width:3px,color:#fef3c7
    style NO fill:#7f1d1d,stroke:#ef4444,stroke-width:3px,color:#fecaca
    style TELL fill:#450a0a,stroke:#ef4444,color:#fecaca
    style CARD fill:#78350f,stroke:#fbbf24,color:#fef3c7
    style RUN fill:#052e16,stroke:#4ade80,color:#dcfce7
    style TAB fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe
    style BACK fill:#052e16,stroke:#4ade80,color:#dcfce7
    style NEXT fill:#052e16,stroke:#4ade80,color:#dcfce7
    style DONE fill:#1e293b,stroke:#64748b,color:#f8fafc

So the shape is synchronous refusal, asynchronous approval:

the refusal

immediate, and free

the human's answer

a card, minutes later, granting a one-shot allowance

when it takes effect

the agent's next attempt

That is a real constraint of this boundary, and worth stating plainly instead of hiding: you cannot make somebody else's server wait for your human. Pretending otherwise produces a demo that hangs.

The wording of the refusal is doing real work too β€” it is project #3's deny-branch lesson, one network hop further out. Say only "denied" and the server's own model reasonably retries the identical call, and each retry costs the agent another round trip. So the text names the price, names the ceiling, and says what would actually help:

REFUSED BY THE HOST'S SPEND GATE. This request is estimated at 6.0Β’, which is over the 2.0Β’ ceiling configured for the server "kitchen". No model call was made and nothing was charged. Do not retry this request unchanged β€” either ask for a smaller maxTokens, send less text, or proceed without the model's help and say plainly what you could not do.

Three things the host refuses to take on trust

All three arrive from across the boundary, and all three are the biggest inputs to the bill:

the server sends

the host does

modelPreferences: { hints: [{ name: "claude-sonnet-5" }] }

treats it as a preference, resolves against an allowlist, defaults to the cheapest model on a miss

maxTokens: 100000

clamps to 1024, with no explanation owed

requestState: "…"

echoes it back without reading it β€” see part 7


Part 5 β€” One wallet, many spenders

A server's model call could plausibly be billed three ways, and only one of them keeps project #4's seatbelt honest.

  1. Its own budget, per server. Tidy, and wrong β€” a per-server pot implies an ownership that does not exist, and lets a server be "in budget" while your run is already broke.

  2. Untracked, outside the tree. How it works if you don't think about it. Your MAX_TREE_TOKENS ceiling then guards everything except the one spender you don't control.

  3. The same TreeBudget the agents draw from, tagged with who asked. ← this one.

Project #4 built one wallet shared by reference across an agent tree so that "the whole run stops at 600,000 tokens" was true rather than approximately true. A server's draw goes on that same tab, or the sentence quietly becomes "600k plus whatever the servers felt like."

One wallet, many spenders, every draw labelled.

The label is the new work, and it is what ledger_entries.server_key is. Which makes a question project #4 could not answer into a group by:

  SPEND BY SERVER
  ──────────────────────────────────────────────────────────
  server        allowed  refused      spent  refused value
  kitchen             8        5      13.0Β’          24.2Β’

The right-hand column is the one people leave out. Refusals get a ledger row too, with actual_cents = 0, because a ledger with no refusals in it is indistinguishable from a ledger whose gate is switched off β€” and you would not find out which until the bill arrived.


Part 6 β€” The regression suite that costs nothing

Project #4's NEXT_STEP.md said to build this before anything else. It was right, and it changed how the whole project was built.

An eval case is two things bolted together, and only one of them costs money:

   a PROMPT   "empty the cookie jar completely"        ← costs money
   a CHECK    "…and some call should have been gated"  ← a pure function

Project #4 already writes every loop event to trace_events, permanently, for the replay UI. So the model's decisions are already on disk. Re-running the check() against a stored trace asks the same question about the same behaviour and costs zero.

  βœ… look-only                [single]  βœ… pantry-sweep         [crew]
  βœ… chain-dice-to-cookies    [single]  βœ… pantry-no-overkill   [crew]
  βœ… no-tools                 [single]  βœ… crew-delegates       [crew]
  βœ… gate-fires               [single]  βœ… crew-one-approval    [crew]
  βœ… picks-right-cipher       [single]  βœ… sampling-happens     [single]
  βœ… reads-history            [single]  βœ… sampling-attributed  [single]
                                        βœ… gate-refuses-expensive
  SCORE: 100%   (13/13 scored)
  Tokens spent by this command: 0

Two design decisions made that trustworthy, and both are about not lying.

The extraction lives in one file. There are now two ways to watch an agent work β€” live from the loop, and replayed from Postgres. If each grew its own "turn events into a score sheet" logic they would drift, and the failure would be vicious: the cheap suite would quietly score things differently from the expensive one while both printed confident percentages. So lib/observation.ts is shared by both, and it is the reason live and replay both say 13/13.

no data is not fail. A case with no stored tape scored nothing, and nothing must never round to zero. Project #4's gotcha 4 was a comparison that reported 0% in both modes and drew a confident conclusion from two identically-broken runs; a replay suite has a brand-new way to produce exactly that lie. The counts are kept apart and named separately.

What replay cannot answer

Stated up front, because a tool that oversells itself gets trusted where it shouldn't be:

replay CAN answer, free

replay CANNOT answer

did I break a check() while editing it?

would the model behave better with a new prompt?

did a refactor change how calls are recorded?

is this tool description clearer?

do #3's six and #4's four still score what they scored?

anything about a case never run live

You need both. But only one of them has to be asked fifty times a day while you refactor, and it is not the expensive one.


Part 7 β€” Data does not stay yours

The multi-round-trip flow lets a server keep state across the two halves of a sampling exchange: it returns an opaque requestState string, and the client hands it back on the retry.

Which sounds like a session, and is not one. The MCP SDK's own documentation is unusually blunt:

"It travels through the client and comes back as attacker-controlled input: a server that lets it influence authorization, resource access, or business logic MUST integrity-protect it (e.g. HMAC or AEAD) and MUST reject state that fails verification. The SDK does not do this for you."

Read that next to project #3's thesis and it is the mirror image β€” and sharper, because the data started out as yours:

project #3

a hint the server sent you is not a permission model

project #5

state you sent the client and got back is not your state either

Data does not stay yours by having been yours.

You minted it, you handed it across a boundary you do not control, and what came back is a string that anything in between could have rewritten. {"jars":3} becoming {"jars":300} is one edit away.

So app/api/kitchen/route.ts signs it on the way out and verifies it on the way back, and the host half never reads it at all β€” it is a courier that does not open the envelope. Four lines, and without them this repo would be a worked example of the bug.


Part 8 β€” Run it yourself

git clone https://github.com/ketankshukla/learn-mcp-agent-ledger.git
cd learn-mcp-agent-ledger
npm install
cp .env.example .env.local     # add ANTHROPIC_API_KEY, then a Neon DATABASE_URL
npm run db:init                # creates the schema and stocks the pantry
npm run dev

Then, in a second terminal β€” the first four cost nothing at all:

npm run evals:replay     # THE $0 SUITE β€” scores runs you already paid for
npm run readonly         # resources + prompts, no model calls
npm run ledger           # who spent what
npm run mcp:list         # transport β€” 4 servers, no AI

npm run sampling         # A SERVER ASKS YOU TO THINK β€” about 0.1Β’
npm run sampling -- --deep   # …and gets REFUSED, for $0.00

The demo the project is aiming at, in three commands:

npm run ledger -- --ceiling kitchen 2     # two cents per request
npm run sampling -- --deep                # πŸ›‘ your host tells your own server no
npm run ledger -- --grant kitchen 8       # a human says "yes, this once"
npm run sampling -- --deep                # …and now it goes through

And project #3's and #4's checkpoints, unchanged:

npm run agent Β· npm run approval Β· npm run crew Β· npm run replay Β· npm run compare
npm run evals -- --all --attempts 1       # the live suite, ~$0.65

The map

flowchart TB
    subgraph LIB["πŸ“ lib/ β€” the brain"]
        direction TB
        L0["<b>spend-gate.ts</b><br/><i>what counts as expensive</i>"]
        L1["<b>sampling.ts</b><br/><i>gate β†’ model β†’ wallet β†’ ledger</i>"]
        L2["pricing.ts<br/><i>a dated fact, not a constant</i>"]
        L3["ledger.ts<br/><i>who spent what</i>"]
        L4["mcp-client.ts<br/><i>hand-written Β· now BOTH eras</i>"]
        L5["observation.ts<br/><i>ONE score sheet, two sources</i>"]
        L6["replay-evals.ts<br/><i>the $0 suite</i>"]
        L7["agent-loop.ts / crew.ts<br/><i>#2, #3 and #4, unchanged</i>"]
    end

    subgraph APP["πŸ“ app/ β€” the face"]
        direction TB
        A1["api/kitchen<br/><i>THE SERVER THAT ASKS</i>"]
        A2["api/pantry Β· api/jar<br/><i>vending machines</i>"]
        A3["api/ledger<br/><i>read the meter, turn the dial</i>"]
        A4["Ledger.tsx<br/><i>the refusal card</i>"]
    end

    L2 --> L0
    L0 --> L1
    L1 --> L3
    L4 --> L1
    L7 --> L1
    L5 --> L6
    L3 --> A3
    A3 --> A4
    A1 -.->|"input_required"| L4

    style L0 fill:#7f1d1d,stroke:#ef4444,stroke-width:3px,color:#fecaca
    style L1 fill:#78350f,stroke:#fbbf24,stroke-width:2px,color:#fef3c7
    style L3 fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe
    style A1 fill:#78350f,stroke:#fbbf24,stroke-width:2px,color:#fef3c7
    style A3 fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe
    style LIB fill:#1e293b,stroke:#475569,color:#f8fafc
    style APP fill:#1e293b,stroke:#475569,color:#f8fafc

Part 9 β€” Where to go next

Upgrade

Why it matters

Score Haiku against Sonnet for sub-agents

One string per agent. Project #4's workers do reading, not judgement. The suite to prove it is now free to run.

Ceilings that learn

A server's first week of draws is a distribution. A ceiling could be a percentile rather than a number somebody typed.

Elicitation in the browser

The plumbing is built and discard_batch uses it; only the UI is missing. Attention is the scarce resource there, not money.

Per-user wallets

Ceilings are per server. In a multi-tenant host they would need to be per server per user, which is the same table and a harder question about defaults.

NEXT_STEP.md is the series' closing retrospective β€” what five projects taught, what is genuinely unfinished, and which doors are open for someone else. Short version: the protocol tour is over, and the ledger this project writes is still nobody's input.


This is part of a series

flowchart LR
    P1["πŸͺ #1<br/>learn-mcp-5-year-old<br/><i>an MCP SERVER</i><br/>offers tools, waits"]
    P2["πŸ” #2<br/>learn-mcp-agent-loop<br/><i>an MCP HOST</i><br/>picks tools, runs the loop"]
    P3["βœ‹ #3<br/>learn-mcp-agent-guard<br/><i>the agent that ASKS FIRST</i><br/>gates, memory, evals"]
    P4["πŸ‘₯ #4<br/>learn-mcp-agent-crew<br/><i>one agent that HIRES HELP</i><br/>sub-agents, one queue"]
    P5["πŸ’Έ <b>#5 β€” you are here</b><br/>learn-mcp-agent-ledger<br/><i>the host that OWNS THE WALLET</i><br/>sampling, a spend gate, a ledger"]

    P1 --> P2 --> P3 --> P4 --> P5

    style P1 fill:#1e293b,stroke:#38bdf8,color:#f8fafc
    style P2 fill:#1e293b,stroke:#38bdf8,color:#f8fafc
    style P3 fill:#1e293b,stroke:#38bdf8,color:#f8fafc
    style P4 fill:#1e293b,stroke:#38bdf8,color:#f8fafc
    style P5 fill:#78350f,stroke:#fbbf24,stroke-width:3px,color:#fef3c7

What it builds

Start here if…

#1 β€” the server

An MCP server

MCP itself is new to you

#2 β€” the agent loop

An MCP host that owns the loop

You want to know what Claude Desktop was actually doing

#3 β€” the gate

Approval gates, Postgres, evals, replay

You want to give an agent a dangerous tool and sleep at night

#4 β€” the crew

Sub-agents, one approval queue, cost measurement

Your agent keeps running out of room on big jobs

#5 (you are here)

Sampling, a spend gate, a ledger

Something you connected can spend your money

Each one is a sequel that reuses the last one's code. This project copies project #4's lib/ wholesale β€” the loop, the crew, the gate, the notebook, the report card β€” and teaches its hand-written MCP client the other half of the protocol.


The three documents

For

Answers

README.md (you are here)

Understanding

What is sampling? Why does it need a gate? What can a host refuse to trust?

BUILD_FROM_SCRATCH.md

Doing

Which commands, in what order, and what breaks along the way?

NEXT_STEP.md

Reflecting

What did five projects teach, and what's genuinely unfinished?


Stack

  • Next.js 16 (App Router, Turbopack) + React 19 + Tailwind v4

  • @anthropic-ai/sdk β€” claude-sonnet-5 for the orchestrator, unchanged from #3 and #4 so eval scores stay comparable; claude-haiku-4-5 available to servers that ask

  • @neondatabase/serverless β€” Postgres over HTTP, because serverless has no connection pool

  • mcp-handler + @modelcontextprotocol/server β€” the kitchen, the pantry and the jar

  • The MCP client is hand-written in lib/mcp-client.ts β€” inherited from project #2, because the protocol is just JSON, and extended here to speak both protocol eras


← project #4: the crew Β· How this was built β†’ Β· The gate itself β†’

Data does not stay yours by having been yours.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

–Maintainers
–Response time
–Release cycle
–Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Hosted MCP server for LLM cost estimation, model comparison, and budget-aware routing.

  • A paid remote MCP for OpenAI Codex agent coordination MCP, built to return verdicts, receipts, usage

  • MCP server for MiniMax H3 multimodal video generation

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ketankshukla/learn-mcp-agent-ledger'

If you have feedback or need assistance with the MCP directory API, please join our Discord server