tollbooth-sample
Educational Weather Stats MCP Service — the reference implementation for buildingTollbooth DPYC monetized API services with Bitcoin Lightning micropayments.
This service wraps the free Open-Meteo weather APIand gates paid tool calls through the Tollboothcredit system using the @runtime.paid_tool() decorator. Domain tools containonly business logic; debit, rollback, balance warnings, and constraint evaluationare handled automatically by the OperatorRuntime. Standard DPYC tools(balance, purchase, Secure Courier, Oracle, pricing, constraints) are delegatedto the wheel via register_standard_tools().
Version: 0.4.2
Build your own operator — the bootstrap-dpyc-operator skill
This repo doubles as a Claude Code plugin. The bootstrap-dpyc-operator skill turns yourexisting REST API, stdio MCP, or HTTP MCP into a monetized DPYC Operator MCP: it clones thistemplate live, wraps your domain logic, and generates a deploy-ready project. You keep writingbusiness logic — the SDK handles payments, identity, vault, audit, and pricing.
Install it in Claude Code:
/plugin marketplace add lonniev/tollbooth-sample
/plugin install bootstrap-dpyc-operator@tollbooth-dpyc
Then ask Claude to "make my API a paid DPYC operator" — the skill activates automatically byits description. It never touches your original code (it emits a sibling <slug>-mcp/ project)and reads this repo's live wheel pin on every run, so it can't go stale.
See skills/bootstrap-dpyc-operator/ for the skill and itsreference guides (canonical pattern, source adapters, sessions & vaults, onboarding checklist).
The DPYC Economy
DPYC stands for Don't Pester Your Customer. It's a philosophy andprotocol for API monetization that eliminates mid-session payment popups,subscription nag screens, and KYC friction.
How it works
Pre-funded balances — Users buy credits via Bitcoin Lightning beforeusing tools. Each tool call silently debits from their balance. Nointerruptions, no "please upgrade" modals.
Nostr keypair identity — Users are identified by a Nostr public key(
npub), not an email or password. One keypair per role, managed by theuser. No account creation forms.UUID-keyed tool identity — Every tool is a
ToolIdentityobject witha deterministic UUID v5 derived from a capability name. Pricing hints comefrom thecategoryfield:Category Pricing hint Use case free0 sats Balance checks, status read1 sat Simple lookups write5 sats Multi-step operations heavy10 sats Expensive queries Actual prices are set dynamically by the operator's pricing model in Neon.
Rollback on failure — If the downstream API fails after a debit,credits are automatically rolled back via a compensating tranche. Theuser never pays for a failed call.
Social Contract — The DPYC ecosystem is a voluntary community boundby transparent, auditable economic rules, with a Certification Chain thatcascades trust from the root:
- Citizens — Users who consume API services
- Operators — Developers who run MCP services (like this one)
- Authorities — Certify operators and collect a small tax on purchases
- First Curator — The root of the chain, mints the initial cert-sat supply
How Tollbooth Monetization Works
ToolIdentity and the frozen tool_id
Each domain tool is registered as a ToolIdentity with a frozen tool_id(an opaque UUID), a capability name, a category (pricing hint), and an intentdescription. Mint the UUID once at the tool's birth — runcapability_uuid("get_current_weather") at a REPL (or uuid.uuid4()), thenpaste the result as a literal constant and never change it again. Freezing theliteral is what lets you rename a capability later without orphaning its pricingrows in Neon. Do not call capability_uuid(...) at runtime; the identitymust live in exactly one place:
from tollbooth.tool_identity import ToolIdentity, STANDARD_IDENTITIES
from tollbooth.runtime import OperatorRuntime, register_standard_tools
from tollbooth.credential_templates import CredentialTemplate, FieldSpec
from tollbooth.credential_validators import validate_btcpay_creds
# Frozen UUIDs — minted once at tool birth, never recomputed.
GET_CURRENT_WEATHER_UUID = "b7327eb8-92b4-5252-84e0-ba3f437a16ed"
GET_WEATHER_FORECAST_UUID = "b6d0e596-3aec-5a62-980b-7875aa04d079"
GET_HISTORICAL_WEATHER_UUID = "5608f3e9-44c4-5b28-9744-704af6d701f0"
# 1. Define domain tool identities
_DOMAIN_TOOLS = [
ToolIdentity(
tool_id=GET_CURRENT_WEATHER_UUID,
capability="get_current_weather",
category="read",
intent="Get current weather conditions",
),
ToolIdentity(
tool_id=GET_WEATHER_FORECAST_UUID,
capability="get_weather_forecast",
category="write",
intent="Get weather forecast",
),
ToolIdentity(
tool_id=GET_HISTORICAL_WEATHER_UUID,
capability="get_historical_weather",
category="heavy",
intent="Get historical weather data",
),
]
TOOL_REGISTRY: dict[str, ToolIdentity] = {ti.tool_id: ti for ti in _DOMAIN_TOOLS}
The @runtime.paid_tool() decorator
Every paid tool is a single decorator away from full DPYC monetization.The decorator takes the tool's frozen tool_id constant and handles debit,balance checks, constraint evaluation, rollback on failure, and low-balancewarnings automatically. Your tool function contains only domain logic:
from typing import Annotated, Any
from pydantic import Field
from fastmcp import FastMCP
mcp = FastMCP("tollbooth-sample", ...)
# Create the runtime with merged standard + domain identities
runtime = OperatorRuntime(
tool_registry={**STANDARD_IDENTITIES, **TOOL_REGISTRY},
operator_credential_template=CredentialTemplate(
service="tollbooth-sample-operator",
version=2,
description="Operator credentials for BTCPay Lightning payments",
fields={
"btcpay_host": FieldSpec(required=True, sensitive=True, ...),
"btcpay_api_key": FieldSpec(required=True, sensitive=True, ...),
"btcpay_store_id": FieldSpec(required=True, sensitive=True, ...),
},
),
credential_validator=validate_btcpay_creds,
...
)
# Delegate all standard DPYC tools to the wheel.
# register_standard_tools returns the slug-prefixed @tool decorator —
# use it for the operator's own paid tools below.
tool = register_standard_tools(mcp, "weather", runtime, ...)
# Decorate each paid domain tool
@tool
@runtime.paid_tool(GET_CURRENT_WEATHER_UUID)
async def current(
latitude: float,
longitude: float,
npub: Annotated[str, Field(
description="Required. Your Nostr public key (npub1...) for credit billing."
)] = "",
dpop_token: str = "",
) -> dict[str, Any]:
"""Get current weather conditions for a location.
Returns temperature, wind speed, and weather code from Open-Meteo.
"""
return await weather.get_current(latitude, longitude)
That is the complete paid tool. No manual debit calls, no try/exceptrollback blocks, no balance-warning plumbing. The decorator:
- Looks up the tool's pricing from the
ToolIdentityregistry by UUID - Extracts
npubfrom the function arguments for billing - Validates
dpop_tokenfor operator proof verification - Debits before calling your function (respecting ConstraintGate discounts)
- Rolls back automatically if your function raises an exception
- Appends a low-balance warning to the response when funds are running low
- Skips all gating in STDIO mode so local development works without credits
Key patterns
register_standard_tools(mcp, "weather", runtime, …) — Registers allstandard DPYC tools (balance, purchase, payment, pricing, Secure Courier,Oracle, constraints) from the tollbooth-dpyc wheel, mounts oracledelegations under <slug>_oracle_*, and returns the slug-prefixed@tool decorator. Capture the return so you can use the same decoratorfor your own paid tools — every wire-exposed name on this operator thenshares one slug prefix.
validate_btcpay_creds — Credential validator that checks BTCPaycredentials at receive time, not at first use. Invalid credentials arerejected immediately during the Secure Courier exchange.
CredentialTemplate — Declares the operator's required secrets(BTCPay host, API key, store ID) so the Secure Courier flow can promptfor the right fields and validate them on delivery.
The npub and dpop_token parameters
Every paid tool must accept npub and dpop_token keyword arguments. Thenpub tells the runtime which patron to bill; dpop_token carries theoperator proof for verification:
npub: Annotated[str, Field(
description="Required. Your Nostr public key (npub1...) for credit billing."
)] = ""
dpop_token: str = ""
The defaults of "" keep both parameters optional in STDIO/dev mode.
What the runtime handles under the hood
Tool call arrives
|
v
@runtime.paid_tool(GET_CURRENT_WEATHER_UUID)
|
+-- UUID lookup in tool_registry -> ToolIdentity + pricing
+-- npub + dpop_token extraction from kwargs
+-- STDIO mode? --yes--> Skip gating, call function directly
|
+-- ConstraintGate evaluation (discounts, surge, supply caps)
+-- Balance check + debit
| |
| insufficient --> Return error (no function call)
|
+-- Call your function
| |
| exception --> Automatic rollback, return error
|
+-- Append low-balance warning if needed
|
v
Return result to caller
Constraint Engine
The ConstraintGate is an opt-in dynamic pricing layer. Enable it by setting:
CONSTRAINTS_ENABLED=true
CONSTRAINTS_CONFIG='{"tool_constraints": {...}}'
Common constraint types (the SDK registry holds more — weather_list_constraint_typesenumerates the full set live):
| Type | Effect |
|---|---|
free_trial |
First N calls are free |
happy_hour |
Discount during specific hours |
temporal_window |
Allow calls only during a time window |
finite_supply |
Cap total invocations globally |
loyalty_discount |
Discount after spending N sats |
bulk_bonus |
Discount after N invocations |
surge_pricing |
Demand-elastic multiplier during high demand |
Use weather_check_price to preview constraint effects without spending credits.
See constraints/example_basic.json,constraints/example_advanced.json, andconstraints/example_surge.jsonfor configuration examples.
Becoming an Operator
New to Tollbooth? See GETTING-STARTED.md for astep-by-step guide covering Nostr keypair setup, Authority enrollment,BTCPay configuration, and deploying your first monetized MCP service.
Quick Start
Local development (no gating)
git clone https://github.com/lonniev/tollbooth-sample.git
cd tollbooth-sample
pip install -e ".[dev]"
python -m tollbooth_sample.server
In STDIO mode, all tools work without credits — great for development.
Deploy on Prefect Horizon
The hosting platform is Prefect Horizon (FastMCP is the runtime/frameworkthe server is built on).
- Push to GitHub
- Connect the repo on Prefect Horizon
- Set environment variables:
TOLLBOOTH_NOSTR_OPERATOR_NSEC— Nostr key for identity bootstrap(the only env var required to boot; all other secrets are deliveredvia Secure Courier credential templates)- (Optional)
CONSTRAINTS_ENABLED=true+CONSTRAINTS_CONFIG=...
Heads-up for operators with long-running tools. By default, claim-check /async jobs run in-memory (
async_jobs.backend: "memory"), which means they donot survive a Horizon recycle (durable_across_recycles: false). That's fine forthis reference sample, which has no long-runners — but if you add a tool that deferswork to a background job, pin the[prefect]extra and deliver the durable-executorsecrets (prefect_api_url/prefect_api_key/closure_seal_key, theLONGRUNNER_CREDENTIAL_FIELDS) via Secure Courier so jobs settle across redeploys.Check your live state anytime withservice_status.async_jobs.
Run tests
pip install -e ".[dev]"
pytest -v
Tool Reference
| MCP tool name | Cost | Description |
|---|---|---|
weather_current |
read | Current weather for lat/lon |
weather_forecast |
write | Multi-day forecast (1-16 days) |
weather_historical |
heavy | Historical weather for a date range |
weather_check_balance |
free | Check credit balance |
weather_purchase_credits |
free | Buy credits via Lightning |
weather_check_payment |
free | Check invoice status |
weather_request_adoption |
free | Request adoption by an Authority (deferred-courtship onboarding) |
weather_check_price |
free | Preview cost (shows constraint effects) |
weather_service_status |
free | Health + constraint config summary |
weather_oracle_how_to_join |
free | DPYC onboarding instructions |
weather_oracle_get_tax_rate |
free | Current certification tax rate |
weather_oracle_lookup_member |
free | Look up a DPYC member |
weather_oracle_about |
free | DPYC ecosystem description |
weather_oracle_network_advisory |
free | Active network advisories |
DPYC Ecosystem
Core
- tollbooth-dpyc — Python SDK (vault, auth, pricing, Lightning, Nostr identity)
- dpyc-community — Governance registry: membership, advisories, threat model
- dpyc-oracle — Community concierge (free onboarding + member lookup)
- tollbooth-authority — Certification backbone (Schnorr-signed certificates)
- tollbooth-sample — Sample Operator (this canonical template)
- tollbooth-pricing-studio — iOS pricing-model editor / operator console
Operators
- cypher-mcp — Monetized graph answers: named Cypher templates over Neo4j/AuraDB
- schwab-mcp — Charles Schwab brokerage data
- thebrain-mcp — TheBrain personal knowledge graph
- excalibur-mcp — X/Twitter posting
- taxsort-mcp — Tax classification + Cloudflare Pages UI
- optionality-mcp — Options analytics (brokerage-data operator)
Advocates & utilities
- tollbooth-oauth2-collector — OAuth2 callback handler (advocate service)
- tollbooth-shortlinks — URL shortener utility
License
Apache-2.0