MCP Billing Spec v0.1

Version: 0.1.0 Date: 2026-09-21 Authors: TombStone Dash LLC License: MIT Reference Implementation: noui.bot (Agent Bazaar)

SS1 Billing envelope

Source: src/app/api/bazaar/proxy/route.ts.

POST /api/bazaar/proxy requires an authenticated consumer. The request body is {tool_id|tool_name, input}: provide tool_id or tool_name, plus input. If both identifiers are supplied, tool_id takes precedence. The route forwards input as the tool arguments.

The success response is {result, meta:{tool, provider, cost_cents, cost, latency_ms, remaining_balance_cents, receipt, receipt_persisted}}. result is mcpResponse.result || mcpResponse; tool is the tool name and provider is the provider name. cost_cents is the actual cost; cost is "Free" for zero, otherwise $ followed by (actualCost / 100).toFixed(4). latency_ms is elapsed time, and remaining_balance_cents is the balance returned by the debit (bazaar_debit_balance), or (owner.balance_cents ?? 0) - actualCost on the read-modify-write fallback.

meta.receipt is the signed receipt for this call (SS3 shape, cost_microcents = cost_cents * 10_000, verify_url: "https://noui.bot/verify"), persisted in bazaar_receipts before the response is sent; free calls carry a receipt with cost_microcents: 0. If the receipt could not be persisted the call still returns 200 (the upstream call already ran and was billed), with meta.receipt: null, meta.receipt_persisted: false, and metadata.receipt_error in the usage log. There is no silent success. Idempotency. An optional Idempotency-Key header (1-128 characters) makes the billed call replay-safe. The same key with the same request body returns the stored response byte-for-byte with the header X-Bazaar-Idempotent-Replay: true, makes no upstream call, and does not debit again. The same key with a different body returns 409 IDEMPOTENCY_MISMATCH without an upstream call. Only 2xx responses are stored, so a failed upstream call may be retried with the same key. Keys are scoped per consumer and stored in bazaar_idempotency_keys (sql/008_proxy_receipts_idempotency.sql) for 24 hours; after that the key is treated as new (the request runs and bills again) and old rows are purged (sql/009_idempotency_ttl_purge.sql). Debit. The balance is debited after a successful upstream call through bazaar_debit_balance(p_consumer, p_cents), a conditional UPDATE … WHERE balance_cents >= p_cents, so concurrent calls cannot overdraw: a call whose debit is refused returns 402 INSUFFICIENT_FUNDS and logs insufficient_funds. When the function has not been applied yet the route falls back to the earlier read-modify-write and tags the usage log metadata.atomic_debit: false.

Success responses and upstream failure responses carry these headers:

HeaderValue
X-Bazaar-CostString(actualCost)
X-Bazaar-Providerprovider.name
X-Bazaar-LatencyString(latencyMs)
X-Bazaar-Tooltool.tool_name
X-Bazaar-Retried"true", present only after a retry
X-Bazaar-Receiptreceipt.receipt_id, present only on success with a persisted receipt
X-Bazaar-Idempotent-Replay"true", present only when the response was replayed from the Idempotency-Key store

Errors use {error:true, code, message, ...}:

CodeHTTP statusCondition and additional fields
UNAUTHORIZED401Missing, invalid, or non-consumer key; docs: "https://noui.bot/docs/bazaar"
BAD_REQUEST400Invalid JSON, or an Idempotency-Key that is empty or longer than 128 characters
VALIDATION_ERROR422Missing tool identifier or input
RATE_LIMITED429Consumer rate limit exceeded; retry_after_seconds: 60, header Retry-After: 60
NOT_FOUND404Tool not found or inactive
UNAVAILABLE503Provider inactive
INSUFFICIENT_FUNDS402Balance below positive price (checked before the call and again by the atomic debit); cost_cents, balance_cents
IDEMPOTENCY_MISMATCH409Idempotency-Key reused with a different request body; no upstream call
RECEIPT_SIGNING_UNAVAILABLE503NODE_ENV=production, RECEIPT_SIGNING_ENFORCE=true and no RECEIPT_SIGNING_SECRET; returned before any upstream call, nothing billed
PROXY_TIMEOUT504Provider timeout; tool, provider, latency_ms, cost_cents
PROVIDER_ERROR502Upstream failure, invalid JSON, or other call error; tool, provider, latency_ms, cost_cents

The upstream request is an HTTP POST containing JSON-RPC 2.0: {jsonrpc:"2.0", method:"tools/call", params:{name:tool.tool_name, arguments:input}, id:crypto.randomUUID()}. Headers are Content-Type: application/json, X-Bazaar-Consumer: owner.id, and X-Bazaar-Tool: tool.tool_name. Each attempt has a 10-second timeout. An HTTP 5xx response triggers one retry after 1 second.

The price is tool.price_cents_override ?? provider.default_price_cents ?? 0. Only status === "success" has a nonzero actual cost, and the balance is debited only when that cost is positive. Each debit also credits the provider through bazaar_credit_provider, net of the 10% platform fee applied to their running gross; a failed credit never fails the call and is recorded as metadata.provider_credit_error on the usage log. The route sets success when the upstream HTTP response is successful, its body parses as JSON, and it carries neither a JSON-RPC error nor an MCP tool failure (result.isError: true). Those two are PROVIDER_ERROR and are not charged.

SS2 Metering event

Source: src/app/api/v1/bazaar/meter/route.ts, including its bazaar_usage_logs insert. Stored receipt statuses come from the CHECK constraint in sql/004_trust_layer.sql.

POST /api/v1/bazaar/meter requires an authenticated provider or consumer key. Its body fields and defaults are:
FieldRoute interpretation and default
tool_idString; required if no tool_name; takes precedence if both are provided
tool_nameString; required if no tool_id
agent_idString; body.agent_id, or the key owner for a consumer key. Required (422) with a provider key
statussuccess, error or timeout, default success (422 otherwise). A consumer key can only send success (403)
duration_msNumber; body.duration_ms || 0
input_tokensNumber; body.input_tokens || 0
output_tokensNumber; body.output_tokens || 0
metadataObject; body.metadata || {}

These are JavaScript truthiness defaults and TypeScript casts, not runtime type validation. The stored receipt status enum is success|error|timeout|rate_limited; the meter route itself does not validate that enum before inserting.

The route looks up an active tool. The resolved tool ID is tool?.id || toolId || "unknown"; the provider ID is tool?.provider_id || (owner.type === "provider" ? owner.id : "unknown"). Price is tool?.price_cents_override ?? tool?.bazaar_providers?.default_price_cents ?? 0.

The usage insert maps agent_id to consumer_id, includes provider_id and resolved tool_id, and records status. cost_cents is the price only for status === "success", otherwise zero. duration_ms becomes latency_ms; input_tokens and output_tokens become request_size_bytes and response_size_bytes. Stored metadata spreads the supplied metadata, then overwrites source with "meter_api" and input_tokens and output_tokens with their defaulted values.

The HTTP 201 response is {metered, tool_id, agent_id, cost_microcents, cost_cents, status, timestamp, receipt:{receipt_id, signature, verify_url}}, with metered: true and a generated ISO timestamp. cost_microcents is (status === "success" ? priceCents : 0) * 10_000. The response's cost_cents is the charged amount: priceCents on success, otherwise zero, matching the usage insert and cost_microcents. Receipt persistence failure is logged but does not fail the meter response.

SS3 Signed receipt

Sources: src/lib/receipts.ts, the bazaar_receipts table in sql/004_trust_layer.sql, and the meter route's receipt insert and verification URL.

FieldRepresentation
receipt_idrcpt_ followed by 16 lowercase hexadecimal characters
tool_idString
tool_nameOptional string; nullable in storage
agent_idString
provider_idString
timestampISO 8601 timestamp
duration_msOptional integer; nullable in storage
cost_microcentsInteger (BIGINT in storage)
statussuccess, error, timeout, or rate_limited
input_hashOptional string; nullable in storage
output_hashOptional string; nullable in storage
signature64-character lowercase hexadecimal HMAC-SHA256 signature
verify_urlhttps://noui.bot/api/v1/bazaar/receipts/{receipt_id}, constructed by the meter route, not a table column

Canonical string: receipt_id|tool_id|agent_id|provider_id|timestamp|cost_microcents|status

signReceipt joins those field values in exactly that order with |, using cost_microcents.toString(). It computes HMAC-SHA256 over that string with the signing secret and encodes the result as hex. verifyReceipt recomputes the signature and compares it with ===. Optional fields and verify_url are not signed.

Public envelope verification

POST /api/v1/verify is public and requires no account or API key. The caller supplies the full signed receipt envelope, either as a bare receipt object or wrapped in {receipt: {...}}. Verification is stateless: it reads no database and returns only the submitted receipt plus its verification outcome. The verification page accepts signed receipt JSON; it does not offer receipt-ID lookup. GET /api/v1/verify?receipt_id=... is not supported and returns HTTP 405 with an empty body.

The response is {receipt, verification:{valid, reason, checked:{canonical, algorithm:"HMAC-SHA256"}, verified_at}}. checked.canonical is built only from caller-supplied fields; it is empty when a signed field is missing or incorrectly typed. Invalid envelopes return HTTP 200 with valid:false; unparsable JSON returns HTTP 400 with {error:true, code:"BAD_REQUEST"}.

The required fields are the seven canonical fields above plus signature. Signed string fields must be nonempty and must not contain |; delimiter collisions return ambiguous_fields before signature comparison. cost_microcents must be an integer. Receipt IDs must match ^rcpt_[0-9a-f]{16,}$ and signatures must be 64 lowercase hexadecimal characters. The verifier uses a timing-safe signature comparison. Reasons are ok, missing_fields (with a missing field list), bad_receipt_id, bad_signature_format, signature_mismatch, ambiguous_fields, and signing_unconfigured (production has no RECEIPT_SIGNING_SECRET, so no receipt can be trusted).

Legacy stored-receipt endpoint

The separate legacy endpoint is GET /api/v1/bazaar/receipts/{receipt_id}. As implemented in src/app/api/v1/bazaar/receipts/[receiptId]/route.ts, it returns {receipt, verification:{valid, algorithm:"HMAC-SHA256", verified_at}}. The returned receipt contains receipt_id, tool_id, tool_name, agent_id, provider_id, timestamp, duration_ms, cost_microcents, status, signature, and created_at. It does not return input_hash, output_hash, or verify_url. verified_at is an ISO timestamp.

Example receipt

The example amount is 500 microcents = 0.05 cents ($0.0005). This fixed development fixture is generated by tests/fixtures/generate-receipt-fixtures.mjs using the real signer and its committed development fallback.

{
  "receipt_id": "rcpt_0123456789abcdef",
  "tool_id": "b0771337-b070-4000-b001-000000000001",
  "tool_name": "wallet.balance",
  "agent_id": "agent_fixture",
  "provider_id": "b0771337-b070-4000-a000-000000000001",
  "timestamp": "2026-09-21T00:00:00.000Z",
  "duration_ms": 42,
  "cost_microcents": 500,
  "status": "success",
  "signature": "ebfbeca8e4fa6d83596d2e4a261c81ad0494d25f68e81be765fe60e1e19bd26b",
  "verify_url": "https://noui.bot/api/v1/bazaar/receipts/rcpt_0123456789abcdef"
}

SS4 Implementation notes

The current meter, pricing, stats, and balance routes use cost_microcents = cost_cents * 10_000 (including the corresponding per-call pricing, aggregate revenue, and balance fields). 1 cent = 10,000 microcents, consistent with public/specs/mcp-billing-v1.md. Failed metering records zero microcents as described in SS2. Existing stored receipts and their signatures are unchanged.

The meter route stores stringified token counts in input_hash and output_hash, rather than SHA-256 digests: inputTokens ? String(inputTokens) : null and outputTokens ? String(outputTokens) : null. This token-count storage behavior is unchanged.

SS5 Relationship to the v1 draft

public/specs/mcp-billing-v1.md is the broader aspirational draft. MCP Billing Spec v0.1 documents the strictly implemented subset in the current routes, receipt signer, and receipt table, including their present behavior and discrepancies; it does not imply implementation of the wider v1 draft.