Inspiration

Someone sells coffee beans online and wants to know whether a USD 45 bag is worth shipping to Korea or Japan. They ask an AI agent. To answer, the agent needs one paid demand lookup and two paid landed-cost estimates: three tool calls, USD 0.90 in total.

Giving the agent a card number doesn't answer the hard questions. Who set the limit? Who said yes to the more expensive calls? If a call is retried, is it paid twice? PayPal already has the pieces for this: an authorization holds money without charging it, and a capture charges an exact amount later. Tollbooth puts those pieces in front of paid MCP tools.

What it does

  1. The buyer approves one PayPal authorization of USD 5. It is a hold, not a charge.
  2. The buyer types a question. A Workers AI planner reads it with the tool catalog and proposes the paid calls. For the coffee question it picks a USD 0.20 demand lookup and two USD 0.35 landed-cost estimates.
  3. Each call runs under a policy: calls above USD 0.30 wait for the buyer's OK, and calls above USD 0.50 are blocked. The agent can't approve its own spending. A declined call adds nothing.
  4. A retried call returns the stored result and adds USD 0.00.
  5. When the buyer finishes, PayPal captures USD 0.90 once. The other USD 4.10 is never charged. A session with no calls has its authorization voided.
  6. The buyer gets a receipt. The tool publisher sees every session and charge on a merchant dashboard.

Any MCP client can connect over Streamable HTTP at /mcp. Paid calls go through a client adapter that carries the session, a call key, a maximum price and the pricing version.

How I built it

PayPal. Orders v2 with intent: AUTHORIZE creates the hold, and the buyer approves it in the PayPal wallet. Payments v2 then sends a single capture with final_capture: true for the metered amount, or a void when nothing was used. Every webhook must pass verify-webhook-signature before it counts. A browser redirect alone is not approval, and a pending capture is not paid. If PayPal's response to a capture is unknown, Tollbooth reads the existing authorization and capture instead of sending a second capture.

AI. The planner is @cf/zai-org/glm-4.7-flash on Workers AI. It gets one planning round per session. Tollbooth checks its plan against the tool catalog, the argument schemas, a six-call cap and the remaining limit, and never rewrites it. The model has no access to payment credentials. If the model is unavailable, the UI says so and offers scripted calls.

Runtime. TypeScript in one Cloudflare Worker. The Cloudflare Agents SDK createMcpHandler serves stateless MCP. Each payment session is a SQLite Durable Object; D1 holds admission, recovery anchors and dashboard projections. Prices are integer cents set by the server, and each call reserves budget before it runs. Ledger rows reach D1 through a durable outbox, and alarms and recovery scans finish settlement after the browser closes.

UI. Vite, React, Tailwind and shadcn/ui on Base UI. The merchant dashboard uses an AG Grid Community table for sessions and charges, including pending and reconciling states. The no-account rehearsal and sample merchant view are labeled "simulated" on every screen.

Tools used during development. AI coding assistants helped write code and tests. The logo concepts were AI-generated; the chosen mark was redrawn by hand as SVG.

Challenges I ran into

A Durable Object doesn't keep its lock across a network await. A late tool call could slip in after the buyer pressed Finish, so I added leases, a closing barrier that waits for in-flight calls, and reserved cents.

PayPal can complete a capture before every read endpoint shows it. Settlement polls the authoritative state and never repeats a capture.

Accomplishments that I'm proud of

  • October 19, 2026, deployed build in PayPal sandbox: the AI plan ran three paid calls and one free replay. PayPal recorded one capture, 2RA569713M861961N, for USD 0.90. A separate zero-use authorization, 2FC89859D10835132, was voided. Six PayPal-signed webhooks passed signature verification and were applied: CHECKOUT.ORDER.APPROVED ×2, PAYMENT.AUTHORIZATION.CREATED ×2, PAYMENT.CAPTURE.COMPLETED ×1, PAYMENT.AUTHORIZATION.VOIDED ×1.
  • October 30, 2026, current code on local wrangler dev against PayPal sandbox (this is the video): the buyer's question produced the same three-call plan, and PayPal captured USD 0.90 once (9FY331048G298983K). A zero-use session was voided.
  • 192 Vitest tests and 15 Playwright tests. Workers-runtime tests deliver the same webhook three times, in sequence and concurrently, and the ledger and capture count change once.
  • The buyer is asked twice in the coffee task: once for each USD 0.35 estimate, not for the USD 0.20 lookup.

Fixed fees are what make sub-dollar calls hard to sell. At PayPal's published US standard rate (3.49% + USD 0.49), charging the three calls separately carries USD 1.47 in fixed fees on USD 0.90 of usage; one capture carries USD 0.49. These are computed from the fee table, not measured, because sandbox charges no fees.

All results are from PayPal sandbox. Tollbooth is not production-ready.

What I learned

An AI plan, a person's consent, a tool result and a PayPal settlement are four separate decisions. Once each had its own ID and its own record, retries became safe and the interface got simpler.

What's next for Tollbooth

  • A full live check of webhook redelivery. Runtime tests cover deduplication, but a live end-to-end redelivery has not been confirmed.
  • More tools in the catalog and compatibility tests with more MCP hosts.
  • Talking to tool publishers about per-call pricing.

Current scope: sandbox only, USD, one merchant, two tools whose data comes from fixed tables.

Built With

  • ag-grid
  • base-ui
  • cloudflare-agents-sdk
  • cloudflare-d1
  • cloudflare-workers
  • durable-objects
  • glm-4.7-flash
  • model-context-protocol
  • paypal
  • paypal-orders-api
  • paypal-payments-api
  • paypal-webhooks
  • playwright
  • pnpm
  • react
  • shadcn-ui
  • sqlite
  • tailwindcss
  • typescript
  • vite
  • vitest
  • workers-ai
  • wrangler
  • zod
Share this project:

Updates

Submission history