使用说明
Tollbooth DPYC
Don't Pester Your Customer — Bitcoin Lightning micropayments for MCP servers.
Patent Pending — US Provisional Application 64/045,999
The metaphors in this project are drawn with admiration from The Phantom Tollbooth by Norton Juster, illustrated by Jules Feiffer (1961). Milo, Tock, the Tollbooth, Dictionopolis, and Digitopolis are creations of Mr. Juster's extraordinary imagination. We just built the payment infrastructure.
The Problem
Thousands of developers are building MCP servers — services that let AI agents interact with the world. Knowledge graphs, financial data, social media, medical records. Each one is a city on the map. But the turnpike between them? Wide open. No toll collectors. No sustainable economics.
Every MCP operator faces the same question: how do I keep the lights on?
Traditional API keys with monthly billing? You're running a SaaS company now. L402 — Lightning-native pay-per-request? Every single API call requires a payment negotiation. Milo's toy car stops at every intersection to fumble for exact change.
The Solution
Tollbooth DPYC takes a different approach — one that respects everyone's time:
Milo drives up to the tollbooth once, buys a roll of tokens with a single Lightning invoice, and drives. No stops. No negotiations. No per-request friction. The tokens quietly decrement in the background. When the roll runs low, he buys another. The turnpike stays fast.
Prepaid credits over Bitcoin's Lightning Network, gated at the tool level, settled instantly, with no subscription management and no third-party payment processor taking a cut.
Quick Start: Build an Operator in 5 Minutes
The canonical reference operator is tollbooth-sample. Every production operator follows this pattern.
1. Install
pip install "tollbooth-dpyc[nostr]" fastmcp
2. Define Your Tools
from fastmcp import FastMCP
from mcp.types import Icon
from tollbooth.tool_identity import READS_REMOTE, STANDARD_IDENTITIES, ToolIdentity
from tollbooth.runtime import OperatorRuntime, register_standard_tools
from tollbooth.credential_templates import CredentialTemplate, FieldSpec
VERSION = "1.0.0"
mcp = FastMCP(
"my-weather-service",
version=VERSION,
website_url="https://github.com/you/my-weather-service",
icons=[Icon(src="https://example.com/icon.png", mimeType="image/png")],
instructions="What this service does, for the agent.",
)
# Domain tools — what your service actually does. The tool_id is a UUID you
# pick once and never change (capability_uuid("...") or uuid4() at the REPL).
GET_WEATHER_UUID = "…"
GET_FORECAST_UUID = "…"
_DOMAIN_TOOLS = [
ToolIdentity(
tool_id=GET_WEATHER_UUID, capability="get_weather", category="read",
intent="Current weather data", title="Current weather",
behavior=READS_REMOTE, # reads, and reaches an upstream weather API
),
ToolIdentity(
tool_id=GET_FORECAST_UUID, capability="get_forecast", category="read",
intent="5-day forecast", title="Weather forecast", behavior=READS_REMOTE,
),
]
TOOL_REGISTRY = {t.tool_id: t for t in _DOMAIN_TOOLS}
Every tool declares a title (its display name) and a behavior — whether it
only reads, overwrites or deletes, is safe to repeat, and reaches beyond your own
store. Agents read these as MCP tool annotations. tollbooth.tool_identity
names the shared ones (READS, READS_REMOTE, WRITES, CREATES_REMOTE,
DELETES, …); build a ToolBehavior for anything else. A tool with no
ToolIdentity is refused at registration.
3. Create the Runtime
runtime = OperatorRuntime(
tool_registry={**STANDARD_IDENTITIES, **TOOL_REGISTRY},
operator_credential_template=CredentialTemplate(
service="my-operator",
fields={
"btcpay_host": FieldSpec(required=True, sensitive=False, description="BTCPay Server URL"),
"btcpay_api_key": FieldSpec(required=True, sensitive=True, description="BTCPay API key"),
"btcpay_store_id": FieldSpec(required=True, sensitive=True, description="BTCPay store ID"),
},
),
service_name="My Weather Service",
# This service's public URL. Connections prove who they act as here:
# signed requests (NIP-98) and OAuth holder connections. See Identity & Proofs.
holder_auth_url="https://my-weather.example.com",
)
Optional — enable field reports (report_issue). No code change required. To let proven
patrons file field reports about your tools as GitHub issues on your repo, just deliver two
secrets on your operator credential service via Secure Courier:
github_repo → "owner/repo" you want reports filed to
github_token → a fine-grained token with issues:write on that repo (no code scope)
The runtime auto-includes these optional fields in your courier template, so no template
edit is needed. The <slug>_report_issue tool is always registered; without the two secrets
it simply returns an "issue reporting not configured" situation. It is proof-gated and priced
(seeds at 1 sat) so it cannot be spammed, and refunds automatically when nothing is filed. The
issue's author of record is the caller's npub, stamped into the body.
4. Register Standard Tools + Domain Tools
# This single call registers every standard DPYC tool and returns
# the slug-prefixed @tool decorator for the operator's own paid tools.
tool = register_standard_tools(mcp, "weather", runtime,
service_name="my-weather-service",
service_version="1.0.0",
)
from tollbooth.params import PatronNpub
@tool
@runtime.paid_tool(GET_WEATHER_UUID)
async def get_weather(city: str, npub: PatronNpub = "") -> dict:
"""Get current weather for a city.
Args:
city: The city to report on.
"""
# Your domain logic here
return {"city": city, "temp": 72, "conditions": "sunny"}
5. Deploy
Set one environment variable and deploy:
export TOLLBOOTH_NOSTR_OPERATOR_NSEC=nsec1...
fastmcp run server.py
That's it. The runtime bootstraps everything else from the Authority via encrypted Nostr DM.
How It Works
The Deploy Flow
- Set
TOLLBOOTH_NOSTR_OPERATOR_NSEC— the operator's Nostr private key. This is the only secret needed at boot. - Runtime bootstraps from Authority — discovers its Neon Postgres URL from an encrypted DM on Nostr relays, signed by the upstream Authority.
- Vault initializes — AES-256-GCM encrypted credential and ledger storage on Neon Postgres, schema-isolated per operator (
op_{hash}). - Deliver operator credentials via Secure Courier — BTCPay connection details arrive via encrypted Nostr DM (human-in-the-loop, never in env vars).
- Configure pricing — set a pricing model via the Pricing Studio iOS app or the
set_pricing_modeltool. - Serve — patrons purchase credits via Lightning, use tools, credits decrement automatically.
Credit Lifecycle
- Patron calls
purchase_credits— the operator obtains an Authority certificate (Schnorr-signed Nostr event) and creates a Lightning invoice. - Patron pays the Lightning invoice with any wallet.
- Patron calls
check_payment— on settlement, credits are added as a tranche with optional expiry (TrancheLifetime). - Patron uses tools —
debit_or_denygates every paid tool call: checks that the connection proves thenpub, then constraints and pricing, and debits the balance. FIFO tranche consumption. - Balance runs low — patron buys more credits. No subscription, no interruption.
The Certification Fee Cascade
When a patron purchases credits, the operator's upstream Authority deducts a small certification fee (default 2%, minimum 10 sats) from the operator's pre-funded reserve. The patron always receives exactly the credits they paid for — the fee is the operator's cost of doing business.
Each Authority is itself an operator of its upstream Authority — the same fee cascades up through the chain to the Prime Authority.
Tool Naming: capability vs runtime name
Every tool the wheel registers has three identifiers and exactly one of them crosses the server boundary.
| Identifier | Example | Scope | Used for |
|---|---|---|---|
capability |
"check_balance" |
Internal only | Python function identifier; seed for the UUID. Lets a slug rename leave Neon pricing rows intact. |
mcp_name / runtime name |
"weather_check_balance" |
External | Wire-exposed tool name, pricing model tool_name, audit logs. The one external identifier. |
tool_id |
UUID5 of capability | Internal | Neon pricing key. Stable across slug renames. |
Use rt.runtime_name("check_balance") whenever you need to refer to a standard tool by name outside this process (e.g., when calling it from another service). Capability names never appear on the wire.
Standard Tools
register_standard_tools(mcp, slug, runtime) registers these tools, prefixed with the operator's slug (e.g., weather_check_balance), and returns the slug-prefixed @tool decorator for the operator's own paid tools. "Operator-only" tools need a connection that proves the operator's npub.
Credit & Billing
| Tool | Cost | Purpose |
|---|---|---|
check_balance |
Free | Current credit balance, tranches, usage summary |
purchase_credits |
Free | Create a Lightning invoice for credit purchase |
check_payment |
Free | Poll invoice status; credit balance on settlement |
check_price |
Free | Preview the effective cost of a tool call (after constraints) |
restore_credits |
Free (operator-only) | Credit a patron from a BTCPay-settled invoice that never reached the ledger |
account_statement |
Free | 30-day purchase and usage history |
account_statement_infographic |
1 sat | SVG visual of account statement |
Identity & Lifecycle
| Tool | Cost | Purpose |
|---|---|---|
service_status |
Free | Operator health, lifecycle state, version info |
session_status |
Free | Operator readiness: ready / warming_up / not_registered / no_identity |
get_operator_onboarding_status |
Free | Which operator credentials are configured vs missing |
get_patron_onboarding_status |
Free | Which patron credentials are configured vs missing |
request_echo |
Free | Echo what the client sent (path, headers, cookies, _meta) as SHA-256 fingerprints — nothing sent is repeated back |
report_issue |
Priced (seeds at 1 sat) | File a field report as a GitHub issue on the operator's repo (see Quick Start) |
Adoption & Recovery
| Tool | Cost | Purpose |
|---|---|---|
request_adoption |
Free (operator-signed) | Ask a chosen Authority to adopt this operator; the Authority's owner approves it |
adoption_status |
Free | Status of this operator's adoption request at an Authority |
restore_neon_schema |
Free (operator-only) | Re-run this operator's idempotent schema setup when Neon keeps answering 4xx |
Secure Courier (credential exchange)
| Tool | Cost | Purpose |
|---|---|---|
request_credential_channel |
Free | Send a credential template DM to a patron or operator |
receive_credentials |
Free | Pick up credentials from vault (instant) or relay (DM) |
forget_credentials |
Free | Delete vaulted credentials for a service |
update_patron_credential |
Free | Add or update a single patron credential field |
delete_patron_credential |
Free | Remove a single patron credential field |
get_patron_credential_fields |
Free | List stored patron credential field names and delivery timestamps |
update_operator_credential |
Free (operator-only) | Add or update one operator secret, preserving the rest of the bundle |
delete_operator_credential |
Free (operator-only) | Remove one operator secret (including stored-but-untemplated leftovers), preserving the rest of the bundle |
Operator fields are set_once, so before update_operator_credential the only way to
apply a reissued secret was to re-deliver the entire bundle over Secure Courier — where
any field left out of the reply is a field destroyed. Prefer the field-level tools when
rotating or retiring one credential; reserve the full courier flow for first delivery,
and forget_credentials only when the whole row should go.
Npub Proof
| Tool | Cost | Purpose |
|---|---|---|
request_npub_proof |
Free | Send a proof challenge DM to a patron; returns the challenge's nonce (holder connections only) |
receive_npub_proof |
Free | Verify the patron's signed reply and put the npub in this holder's wallet (holder connections only) |
check_proof_status |
Free | check_proof_status(patron_npub) — whether this connection proves the npub, and for how long |
held_identities |
Free | The npubs this connection holds, with when each grant expires |
Nostr Profile
| Tool | Cost | Purpose |
|---|---|---|
get_nostr_profile |
Free | Read an npub's public kind-0 profile |
publish_nostr_profile |
Free | Relay a client-signed kind-0 profile after verifying its signature matches the npub — the wheel never holds a patron nsec |
Authority Balance
| Tool | Cost | Purpose |
|---|---|---|
check_authority_balance |
Free | Operator's cert-sat balance at the upstream Authority |
Oracle Delegation
| Tool | Cost | Purpose |
|---|---|---|
<slug>_oracle_* |
Free | Delegated calls to the DPYC Oracle: about, how_to_join, get_tax_rate, lookup_member, network_advisory. Mounted under the operator's slug — e.g. schwab_oracle_about, brain_oracle_how_to_join — so every wire-exposed tool on a given operator shares one slug prefix. |
Pricing Model
| Tool | Cost | Purpose |
|---|---|---|
get_pricing_model |
Free | Current pricing model with tool registry and constraints |
set_pricing_model |
Free (operator-only) | Update the pricing model |
reset_pricing_model |
Free (operator-only) | Delete all pricing models and re-initialize |
list_constraint_types |
Free | Available constraint types and their parameters |
list_canonical_identities |
Free | Canonical (tool_id, mcp_name, category, intent, registered) inventory — source of truth for Reconcile; @paid_tool UUIDs missing from the ToolIdentity table appear with registered: false / in unregistered[] |
Coupons
| Tool | Cost | Purpose |
|---|---|---|
mint_coupon |
Free (operator-only) | Create a discount coupon bound to the tools it discounts (tool_ids) |
list_coupons |
Free (operator-only) | Every coupon this operator has minted, with redemption counts |
update_coupon |
Free (operator-only) | Change a coupon's name, discount, window, caps or bound tools |
delete_coupon |
Free (operator-only) | Delete a coupon and its redemptions |
redeem_coupon |
Free | Patron claims a coupon by its code; later paid calls apply the discount |
list_my_coupons |
Free | The coupons a patron has redeemed, with each one's status |
forget_coupon |
Free | Remove a coupon from the patron's redemption list (the coupon itself remains) |
OpenTimestamps (when ots_enabled=True)
| Tool | Cost | Purpose |
|---|---|---|
notarize_ledger |
Free | Anchor all ledger state to Bitcoin via Merkle tree + OTS |
get_notarization_proof |
Free | Merkle inclusion proof for a specific patron |
list_notarizations |
Free | All Bitcoin-anchored ledger snapshots |
Conditional Tools
| Tool | Condition | Purpose |
|---|---|---|
request_patron_credentials |
patron_credential_template set |
Open Courier channel for patron-specific credentials |
receive_patron_credentials |
patron_credential_template set |
Pick up patron credentials |
begin_oauth |
oauth_provider set |
Start OAuth2 authorization flow |
check_oauth_status |
oauth_provider set |
Complete OAuth2 flow after browser authorization |
Authority Extension (tollbooth.authority)
Authority MCPs — the institutions that certify operators' purchase orders — get their own protocol surface that mirrors register_standard_tools for operators. Since v0.22.0, the full Authority tool set lives in the wheel under tollbooth.authority and mounts onto a FastMCP app via one call:
from fastmcp import FastMCP
from tollbooth.authority import (
AUTHORITY_TOOL_REGISTRY,
OPERATOR_CREDENTIAL_TEMPLATE,
register_authority_tools,
)
from tollbooth.runtime import OperatorRuntime, register_standard_tools
from tollbooth.tool_identity import STANDARD_IDENTITIES
mcp = FastMCP("tollbooth-authority-mine", instructions="…")
runtime = OperatorRuntime(
tool_registry={**STANDARD_IDENTITIES, **AUTHORITY_TOOL_REGISTRY},
vault_source="env", # Authority self-provisions its Neon from env
purchase_mode="auto", # derive direct/certified from the registry chain
ots_enabled=True,
operator_credential_template=OPERATOR_CREDENTIAL_TEMPLATE,
)
register_standard_tools(mcp, "authority", runtime, ...)
register_authority_tools(mcp, runtime)
That's the entire Authority server.py. Everything below is wheel-provided.
What register_authority_tools mounts
| Tool | Cost | Purpose |
|---|---|---|
update_operator |
Free (operator) | Ask to change the operator's registry entry; waits for the owner's approval |
deregister_operator |
Free (operator or owner) | Remove an operator from the DPYC community registry — the operator leaving, or the owner removing it |
get_operator_config |
Free (proof-gated) | Retrieve the operator's bootstrap config (Neon URL, schema) |
operator_status |
Free | Operator registration status + Authority's Nostr npub |
certify_credits |
Ad valorem (default 2%, min 10 sats) | Sign a Schnorr-signed Nostr event certificate (kind 30079) for a purchase order |
check_dpyc_membership |
Free | Look up an npub in the dpyc-community registry |
register_authority_npub |
Free | Step 1 of Authority self-claim — DM challenge to the candidate |
confirm_authority_claim |
Free | Step 2 — verify candidate's DM, escalate to parent Authority |
check_authority_approval |
Free | Step 3 — confirm parent's approval, activate Authority |
receive_adoption_request |
Free (signed by the operator) | Inbound: record an operator's request to be adopted |
list_operator_requests |
Free (owner) | Owner queue — pending adoption and update requests |
approve_operator_request |
Free (owner) | Approve a pending request: provision an adoption, or apply an update |
reject_operator_request |
Free (owner) | Reject a pending adoption or update request |
get_adoption_status |
Free | Read the status of an operator's adoption request |
repair_operator_schema |
Free (owner) | Reassign all table ownership in an operator's tenant schema to its own role |
list_neon_alerts |
Free (owner) | Owner queue — operators that reported a Neon-402 (persistence store locked) |
network_persistence_health |
Free (owner) | Proactive per-project Neon quota posture + reactive alerts |
Operators join only by adoption: the operator calls its own request_adoption, signed with its key, and the owner approves it in the queue. There is no one-step registration. Each request proves one npub, so anything needing both parties is a request and an approval (docs/deferred-operator-adoption.md). Owner tools take a request that proves the Authority's own npub.
The 3-step Authority onboarding flow escalates to whichever upstream Authority the candidate's dpyc-community registry entry names — Prime for direct-of-Prime, NA for NE, etc. (tollbooth.registry.resolve_my_parent_npub, added v0.20.0).
What the package exports
tollbooth.authority is a thin facade over its supporting modules. The core six were promoted from forked Authority repos in v0.21.0–v0.22.0:
onboarding—OnboardingState,OnboardingChallenge, claim/approval credential templatesnostr_signing—AuthorityNostrSigner(Schnorr certificate signer)replay—ReplayTracker(anti-replay JTI window)tenant_provisioner— Neon schema + LOGIN-role provisioningrole_migration— CLI for migrating legacy schemas to per-operator rolessettings—AuthoritySettings(pydantic-settings env reader)
Three more were added as the Authority took on adoption and Neon-quota stewardship:
operator_requests— durable queue of operator requests (adoptions and registry updates)neon_admin— Neon control-plane client for the proactive compute/storage-quota watchneon_alert_store— durable store for operator Neon-402 quota alerts
Each runtime singleton (signer, replay tracker, settings, onboarding state) is lazy-initialized on first tool call; Authority MCPs are one-per-process so module-level state is effectively per-Authority.
Architectural note — Authority repos as thin consumers
The three reference Authority deployments (tollbooth-authority, tollbooth-authority-northamerica, tollbooth-authority-newengland) used to fork ~1000 lines of identical Authority code each. As of wheel v0.22.0 their server.py files are ~80 lines of actor-specific configuration: FastMCP name, human-readable instructions, OperatorRuntime construction, and the two register_*_tools calls. Adding a new regional Authority is now a scaffold-and-pin operation, not a fork.
OperatorRuntime
OperatorRuntime is the core protocol engine. All DPYC operations — bootstrap, billing, credentials, pricing, constraints — are delegated to it.
Constructor Parameters
OperatorRuntime(
# Required
tool_registry={**STANDARD_IDENTITIES, **YOUR_TOOLS}, # UUID-keyed ToolIdentity map
# Credential templates
operator_credential_template=CredentialTemplate(...), # BTCPay + operator secrets
patron_credential_template=CredentialTemplate(...), # Per-patron API keys (optional)
operator_credential_greeting="Welcome message...",
patron_credential_greeting="Welcome message...",
credential_validator=validate_fn, # Called on credential receipt
# Identity & network
nsec_env_var="TOLLBOOTH_NOSTR_OPERATOR_NSEC", # Env var name (default)
service_name="My Service",
holder_auth_url="https://my-service.example.com", # Enables signed requests + holder connections
# Relays come from the DPYC Oracle — not a constructor argument.
# Billing
vault_source="authority", # "authority" (default) or "env" (self-provisioned)
purchase_mode="certified", # "certified" (default), "direct", or "auto" (registry-derived)
operator_settings={}, # Arbitrary operator config dict
# Constraints
constraint_gate=None, # Legacy — use pricing model pipeline instead
# OpenTimestamps
ots_enabled=True,
ots_calendars=["https://a.pool.opentimestamps.org"],
# Npub proof
npub_proof_field="confirm", # Field name in proof DM
npub_proof_greeting="...",
on_npub_proven=async_callback, # Called when proof verified
# OAuth2
oauth_provider=OAuthProviderConfig(...), # Enables begin_oauth / check_oauth_status
# Lifecycle
on_forget=callback, # Called when credentials are forgotten
)
Key Methods
| Method | Returns | Purpose |
|---|---|---|
debit_or_deny(tool_id, npub, *, tool_kwargs) |
int (cost) or dict (denial) |
Gate a tool call through identity, proof, constraints, pricing, and billing |
paid_tool(tool_id) |
decorator | Decorator that wraps debit_or_deny around a tool function |
prove_caller(npub) |
dict | None |
The one proof gate: does the request in flight prove npub? None on success, else a structured error dict to return verbatim |
require_caller_proof(npub) |
dict | None |
prove_caller for free / bootstrap standard tools |
runtime_name(capability) |
str |
Resolve a capability seed to its runtime tool name (<slug>_<capability>) — what crosses every server boundary |
mcp_name_for(tool_id) |
str |
Resolve a tool UUID to its runtime tool name |
vault() |
NeonVault |
Bootstraps and returns the Neon vault (lazy) |
ledger_cache() |
LedgerCache |
Returns the write-behind ledger cache (lazy) |
courier() |
SecureCourierService |
Returns the Secure Courier (lazy) |
operator_npub() |
str |
Derives the operator's npub from nsec |
resolve_npub(npub) |
str |
Validates an npub (bech32 decode) |
resolve_expiry() |
Expiry |
The operator's credit-expiry decision from the pricing model: unchosen, never, or days |
load_credentials(fields) |
dict |
Loads operator credentials from the vault |
graceful_shutdown() |
— | Flushes caches and closes connections |
Pricing & Constraints
Tool Pricing
Every tool has a ToolIdentity that declares its pricing:
ToolIdentity(
tool_id=GET_WEATHER_UUID,
capability="get_weather",
category="read", # read, write, heavy, free
intent="Current weather",
title="Current weather",
behavior=READS_REMOTE,
pricing_hint_type="ad_valorem", # fixed or ad_valorem
pricing_hint_value=1, # base cost in sats
pricing_hint_param="symbols", # parameter for ad valorem scaling
pricing_hint_min=1, # minimum cost
)
The ToolPricing engine computes the final cost: fixed + ceil(percentage * param_value), clamped to [min, max].
Constraint Pipeline
Operators configure constraints in the pricing model's pipeline array. Each step runs on every paid tool call via debit_or_deny. The effective cost (after discounts, free trials, surge) is what the patron pays.
| Constraint | Purpose |
|---|---|
free_trial |
First N invocations free per patron |
happy_hour |
Time-windowed free/discount with recurrence |
surge_pricing |
Demand-elastic pricing via global counters |
temporal_window |
Time-of-day / day-of-week access windows |
finite_supply |
Total call quotas (per patron or global) |
periodic_refresh |
Rate limiting with ISO-8601 refresh windows |
coupon |
Code-based discounts with expiration — bound by the coupon itself (mint_coupon / update_coupon tool_ids), never authored into a chain |
loyalty_discount |
Spend-based tiered discounts |
bulk_bonus |
Volume bonuses on credit purchases |
patron_proof |
Require each request to be signed with the patron's own nsec (a holder connection does not satisfy it) — for high-value tools |
json_expression |
Custom boolean logic rules |
Scoping: Each pipeline step can target specific tools (tool_ids) and/or specific patrons (patron_npubs, max 10). Unscoped steps apply to all tools and patrons.
TrancheLifetime
Credit expiry is a property of money, not a per-tool constraint. TrancheLifetime in the pricing model sets how long purchased credits remain valid. Each purchase creates a tranche; FIFO consumption.
Credential Delivery
All secrets — BTCPay keys, API tokens, OAuth credentials — are delivered via Secure Courier, not environment variables. This is a human-in-the-loop flow using NIP-44 encrypted Nostr DMs:
- AI agent calls
request_credential_channel(sender_npub=..., service=...), which returns the channel'snonce - Operator/patron receives a DM in their Nostr client with a credential template
- They fill in the fields and reply manually
- AI agent calls
receive_credentials(sender_npub=..., service=..., nonce=...) - Credentials are validated, encrypted (AES-256-GCM with AAD), and stored in the Neon vault
Vault-first lookup means returning patrons activate instantly — no relay I/O needed.
On first relay receipt, the service sends an ncred1... credential card back via DM. Patrons can scan or paste this card to reactivate later.
x402 Upstream Encapsulation
Operators who consume Coinbase x402-protected APIs can absorb the 402 payment ceremony transparently. Patrons never see the 402 handshake — the Operator pays upstream USDC fees as COGS, like server rental.
from tollbooth.x402_client import X402Client
# Wallet credentials delivered via Secure Courier (service "x402-wallet")
creds = await runtime.load_credentials(["wallet_private_key", "wallet_address"])
x402 = X402Client(
wallet_private_key=creds["wallet_private_key"],
wallet_address=creds["wallet_address"],
)
@runtime.paid_tool(tool_id)
async def fetch_upstream(query: str, npub: PatronNpub = "") -> dict:
resp = await x402.get(f"https://x402-api.example.com/data?q={query}")
return resp.json() # patron sees data, never sees 402
Per-tool opt-in: only tool handlers that hit x402 upstreams use the client. Requires pip install tollbooth-dpyc[x402]. No refunds, no rebates — the Operator prices their tools to cover upstream costs with margin.
Identity & Proofs
The full description — intention, protocol, lifetimes and usage for agents, web pages, keyed programs and operators — is docs/identity-proofs.md.
Every participant is identified by a Nostr keypair. The npub is your identity in the DPYC Social Contract. The nsec stays with you — never shared, never sent to a service.
Every tool takes the npub it acts as, and only that: the connection proves it. Each request acts as exactly one npub. An action that needs two parties (an operator and its Authority) is a request by one and an approval by the other, never one request carrying both.
A connection proves an npub in one of two ways, both read from the HTTP Authorization header. Setting OperatorRuntime(holder_auth_url="https://<your-host>") turns them on.
Signed requests (a caller that holds the nsec). An Operator calling its Authority, an agent with its own keyring, the native Pricing Studio: each signs every HTTP request with the npub's key, NIP-98 style. The header is Authorization: Nostr <base64 kind-27235 event>, whose tags name the URL (u), the method, the sha256 of the body (payload) and a random nonce. The signature authorizes that one request and nothing else; events older than 60 seconds and replays are refused. The signer needs no grant.
from tollbooth.identity_proof import sign_request
from tollbooth.mcp_client import service_client
header = sign_request(nsec, method="POST", url=url, body=body) # one request, by hand
client = service_client(url, nsec) # a FastMCP client that signs every request
Holder connections (a caller that holds no key). An AI assistant or a web page connects with OAuth 2.1 (dynamic client registration, Authorization Code with PKCE, single-use refresh tokens); the Operator is its own authorization server. Connecting asks the person nothing, because the token names a holder: a connection with no identity of its own. The holder carries a wallet of npubs, each proven once by the patron answering a Secure Courier DM:
request_npub_proof(patron_npub)sends the patron a challenge DM and returns itsnonce. The challenge is bound to the holder that asked, so only that holder can collect the reply.- The patron replies from their own Nostr client, optionally stating how long the proof should last; the reply is signed by the patron's key.
receive_npub_proof(patron_npub, nonce)drains the pinned relay, verifies the signed reply, and puts a grant in the holder's wallet. The reply to the caller carries no grant.
The grant is an Operator-signed kind-30080 event binding the patron's npub, the hash of the nonce, the patron's verified reply signature, and the expiry the patron chose (up to a 30-day cap). The wallet keeps it as {"grant": <event>}. On each call the gate checks the Operator's signature, the npub and the expiry; forget_credentials revokes every grant issued to a patron. Many npubs can share one connection, and the grant never enters the model's transcript. held_identities lists what a connection holds, and check_proof_status(patron_npub) reports whether it proves an npub and for how long.
Any other request is anonymous and acts as no npub: free tools answer it, and a paid tool returns proof_required with both ways to prove the npub. request_npub_proof and receive_npub_proof refuse a connection that is not a holder.
A service that keeps no holders, such as the dpyc-oracle, installs tollbooth.holder_auth.SignedRequestProvider: a signed request proves its npub, every other request is anonymous and never refused at the door, and tools read the signer with signed_npub().
Architecture
tollbooth-authority tollbooth-dpyc (this wheel) your-mcp-server
================================ ================================ ================================
Schnorr signing + tax ledger OperatorRuntime OperatorRuntime(tool_registry=...)
certify_purchase -> Nostr cert register_standard_tools(mcp, ...) register_standard_tools(mcp, ...)
Authority BTCPay debit_or_deny (gate + billing) @runtime.paid_tool(uuid)
Secure Courier + vault Domain-specific tools
Pricing resolver + constraints
DPYCRegistry (service discovery)
Dependency flows one way: your-mcp-server --> tollbooth-dpyc. Authority is a network peer, not a code dependency.
Ecosystem Repos
| Repo | Role |
|---|---|
| tollbooth-sample | Canonical reference operator — start here |
| tollbooth-authority | Tax collection, Schnorr signing, purchase order certification |
| dpyc-community | Membership registry, governance, service discovery |
| dpyc-oracle | Community concierge — onboarding, tax rates, membership |
| thebrain-mcp | Production operator — PersonalBrain knowledge graph |
| excalibur-mcp | Production operator — social media posting |
| schwab-mcp | Production operator — brokerage data with OAuth2 |
| cypher-mcp | Production operator — monetized graph answers via named Cypher templates over Neo4j/AuraDB |
| taxsort-mcp | Production operator — tax sorting + classification with Cloudflare Pages UI |
| optionality-mcp | Production operator — options analytics |
| goodearth-mcp | Production operator — region-scoped farm climate analytics |
| beesknees-mcp | Production operator — a multiplayer race to the queen |
| roastify-mcp | Production operator — Roastify coffee catalog and packaging artwork |
| ChartRemotely-mcp | Production operator — relays chart commands to a patron's desktop agent |
| tollbooth-shortlinks | Community utility — URL shortener |
| tollbooth-oauth2-collector | Community utility — OAuth2 callback mailbox |
| tollbooth-web | Shared browser SDK for operator front ends |
| tollbooth-pricing-studio | iOS pricing editor — Nostr DMs, native crypto |
Install
# Standard install — includes Nostr relay support (Secure Courier, audit trail)
pip install tollbooth-dpyc[nostr]
# With QR code rendering for credential cards
pip install tollbooth-dpyc[nostr,qr]
Core dependencies (httpx, pynostr) handle identity, proofs, and HTTP. The [nostr] extra adds websocket-client for Nostr relay I/O — required by every real operator since Secure Courier credential delivery, audit publishing, and bootstrap all use relay DMs. The [qr] extra adds segno for credential card QR rendering. The [x402] extra adds x402 + eth-account for transparent Coinbase x402 upstream encapsulation.
# With x402 upstream encapsulation (for operators consuming x402-protected APIs)
pip install tollbooth-dpyc[nostr,x402]
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
TOLLBOOTH_NOSTR_OPERATOR_NSEC |
Yes | The single bootstrap key. Identity, Secure Courier, audit signing. |
NEON_DATABASE_URL |
Trust-root only | Neon Postgres URL. Only for vault_source="env" (self-provisioned Authority). |
Certified operators (the default) do not set NEON_DATABASE_URL. They discover it from the Authority via encrypted Nostr DM during bootstrap.
The Nostr relay set is not an env var — every server fetches it from the DPYC Oracle (get_relays), which serves relays.json from the dpyc-community repo. It is cached in process for an hour.
All other secrets (BTCPay, API keys, OAuth tokens) flow through Secure Courier — never env vars.
Vault & Persistence
Neon Postgres
NeonVault provides ACID ledger storage with AES-256-GCM encryption (with AAD), optimistic concurrency, and schema-qualified table names via _t().
NeonCredentialVault stores credentials encrypted in schema-isolated Postgres (one op_{hash} schema per operator, provisioned when the Authority approves its adoption).
Schema Isolation
The Authority uses the authority schema. Each certified operator receives an isolated schema (op_{hash}) with a dedicated Postgres LOGIN role. NeonCredentialVault is schema-aware via _t().
Nostr Integration
Audit Trail
NostrAuditPublisher publishes a kind-30078 NIP-78 event on every vault write. Content is NIP-44v2 encrypted — only the patron's nsec can read it. Wrap any vault with AuditedVault to add the trail transparently.
Low-Balance Notifications
NotificationManager sends proactive NIP-44 DMs when a patron's balance crosses thresholds or tranches approach expiration. Fire-and-forget — never blocks the tool path.
Credential Cards
ncred1... bech32-encoded credential cards package encrypted credentials into scannable QR codes. Built on kind-21420 Nostr events with NIP-44v2 encryption.
OpenTimestamps Bitcoin Anchoring
Anchor ledger state to the Bitcoin blockchain for irrefutable, timestamped proofs. MerkleTree builds a SHA-256 tree over all (npub, ledger_json) entries. OTSCalendarClient submits the root hash to public OTS calendar servers (no API key required).
Development
git clone https://github.com/lonniev/tollbooth-dpyc.git
cd tollbooth-dpyc
python -m venv venv
source venv/bin/activate
pip install -e ".[dev,nostr,qr]"
pytest tests/ -q
Further Reading
The Phantom Tollbooth on the Lightning Turnpike — the full story of how we're monetizing the monetization of AI APIs, and then fading to the background.
Trademarks
DPYC, Tollbooth DPYC, and Don't Pester Your Customer are trademarks of Lonnie VanZandt. See the TRADEMARKS.md in the dpyc-community repository for usage guidelines.
License
Apache 2.0 — see LICENSE.
Because in the end, the tollbooth was never the destination. It was always just the beginning of the journey.