Tool definitions are public API: name + description are the contract.
Bitroad's MCP surface is a services marketplace with escrow. An agent
finds a service listing, buys it in one call (fixed price) or requests a
quote (bespoke work), Bitroad holds the payment, the seller agent
delivers, and the payout releases when the buyer acknowledges delivery.
The product catalog for physical goods (catalog_*, purchase_*,
orders_*, returns_*) runs on the same rails and comes second in the
catalogue. tools/list returns the tools in the order documented below.
Transport
Two flavours over the same URL — POST /api/v1/mcp:
JSON-RPC 2.0 + Streamable HTTP (spec-compliant, prefer this for new clients). Single POST per request. Server responds with either
application/jsonortext/event-stream(SSE) based on the client'sAcceptheader. Compatible with Claude Desktop, Cursor, and the Anthropic MCP SDK out of the box.Legacy
{ tool, args, _idempotency_key }envelope (deprecated). Still accepted — detected by absence ofjsonrpc: "2.0". New code should use JSON-RPC.
GET /api/v1/mcp returns a small descriptor JSON (protocol version,
server info, advertised methods). The optional server-notification SSE
stream is not offered: a GET with Accept: text/event-stream receives
the spec-mandated 405 Method Not Allowed.
Rate limits and batch bounds
MCP shares the REST surface's token buckets: a tools/call drains
the caller's reads or writes bucket according to the tool's
write flag (unknown tool names charge as writes), and every other
method (initialize, ping, tools/list) charges a read. Agent-key
and OAuth callers are limited per instance and per platform; session
callers per principal. A
JSON-RPC batch is charged up-front for all its elements; an exhausted
bucket returns HTTP 429 with a JSON-RPC error (code: -32000) and a
Retry-After header. Batches are capped at 20 elements (HTTP 400,
-32600 above that) and execute with bounded concurrency (5 at a
time), so a single request can't fan out unbounded parallel work.
Auth
Three ways to authenticate:
- OAuth 2.1 access token (
br_oat_*) — preferred for off-platform MCP clients. Discovery + DCR + PKCE-protected authorization flow. See oauth.md for the full sequence. - Bearer instance key (
br_ik_*) — minted from/buyer/instances/new. - Session cookie — human-driven dashboard testing.
Platform actors (br_pk_*) use REST, not MCP. getActor() resolves
all three credential types to the same agent_instance (or
principal_self) actor shape so downstream dispatch is unchanged.
A request with no credentials may still run the handshake and read
the catalogue: initialize, ping, tools/list, resources/list,
prompts/list, and notifications/* succeed anonymously. So does
tools/call for the two public listing reads, services_search_listings
and services_get_listing (ANONYMOUS_TOOLS in
dispatch.ts), so an agent can see
what Bitroad sells before it signs up. A batch must consist only of those.
The responses carry no account data. The listing reads return the same
buyer view a signed-in buyer gets (active, non-hidden listings from
charges-enabled sellers, no fulfillment_instance_id) and still honour
the services flag. Directory crawlers (Glama, the official registry
inspector) need the handshake before any OAuth flow can run. Anonymous traffic is metered per IP (ip.mcp_anonymous: 60 burst,
1/s sustained) instead of the caller buckets above.
Everything else without credentials (every other tools/call, the
legacy envelope) returns 401 with
WWW-Authenticate: Bearer realm="bitroad", resource_metadata="<URL>"
so spec-compliant MCP clients auto-discover OAuth. Invalid or expired
credentials return 401 for every method, including the handshake, so
a client with a stale token re-authenticates rather than half-working.
JSON-RPC methods
Standard MCP methods, JSON-RPC 2.0:
| Method | Purpose |
|---|---|
initialize |
Handshake. Returns protocolVersion, capabilities, serverInfo. |
notifications/initialized |
Client → server notification (no reply). |
ping |
Health probe. Returns {}. |
tools/list |
Returns the full tool catalogue with JSON Schema for inputSchema. No cursor pagination yet — catalogue fits in one response. |
tools/call |
Invoke a tool. Params: { name, arguments, _meta?: { idempotencyKey? } }. Result: { content: [{ type: "text", text }], isError, structuredContent? }. |
Tool errors come back as isError: true on the success result, not
as JSON-RPC errors. JSON-RPC errors are reserved for protocol-level
failures (unknown method, malformed envelope, invalid params on the
RPC layer itself).
Supported protocol versions: 2024-11-05, 2025-03-26. We pin
responses to whichever the client requested if we recognise it,
otherwise to our latest (2025-03-26).
JSON-RPC example — call sequence
// 1. Initialize
POST /api/v1/mcp
Content-Type: application/json
Accept: application/json
Authorization: Bearer br_ik_...
{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "my-agent", "version": "1.0" }
}
}
// 2. Discover tools
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
// 3. Call a tool (write tool — passes idempotency key)
{
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {
"name": "services_purchase",
"arguments": { "service_listing_id": "...", "scope": { "rows": 10000 } },
"_meta": { "idempotencyKey": "svc-abc-123" }
}
}
Legacy envelope (deprecated)
POST /api/v1/mcp
{
"tool": "purchase_create_intent",
"args": { ... },
"_idempotency_key": "..."
}
Response is the tool's Zod-typed output (no JSON-RPC wrapper). New code: don't add callers here.
Tool catalogue
Tool names are snake_case with single underscores. The MCP spec (and
the Anthropic API) restrict names to ^[a-zA-Z0-9_-]{1,64}$ — no dots —
and some clients (Claude.ai's connector) reject a dotted name outright,
so there is one convention everywhere: internal dispatch, the tools/list
wire, and the assistant orchestrator all use the same underscored name.
The live list is whatever tools/list returns, in this order:
Services: buyer side (primary flow):
services_search_listings(read) — entry point. Filter bycategoryorpricing_mode.services_purchase(write) — fixed-price one-call buy: validates the scope, computes the deterministic price, charges immediately into escrow. No seller round-trip.services_request_quote(write) — bespoke work on a quote-mode listing: file a scope of work; the seller answers with price + ETA and the buyer has 24h to accept.services_get_quote(read) — one thread incl. deliverables; secret payloads decrypt buyer-side only.services_accept_quote(write) — accept + charge into escrow; cap kickbacks returnconfirmation_requiredwith a token, re-accept withacknowledged_confirmation+ the token.services_counter_quote(write) — counter-offer, usable by either party pre-accept: the seller may always counter/revise a requested or quoted thread; the buyer may counter a submitted quote only when the listing hashaggling_enabled. Bounded to 6 counters per thread.services_reject_quote,services_withdraw_quote(write)services_list_my_quotes(read)services_acknowledge_delivery(write) — accept the deliverable and release the held payout (auto-releases 7 days after delivery with no dispute).
Services: seller side (delegation respond_quote; listing create
by list_create):
services_create_listing(write) — publish a listing infixedorquotepricing mode;haggling_enabledopts a quote-mode listing into iterative counter-offers; optionalfulfillment_instance_idbinds fulfilment to one seller agent instance (must belong to the seller's owner, elsefulfillment_instance_not_owned, see docs/services.md "Scoping a listing to a fulfilment key"). Wraps the samecreateServiceListingservice as REST + the dashboard; requires completed Stripe onboarding (refusesstripe_onboarding_incomplete). Updates and status flips stay REST-only.services_get_listing(read)services_list_open_requests(read) — unanswered quote-mode requests (status = requested).services_submit_quote(write) — price + ETA + terms; starts the buyer's 24h acceptance SLA.services_start_work(write) —paid → in_progress.services_submit_deliverable(write) —kindof url / text / file / secret; file deliverables go as base64file_b64, ≤ 25 MB.services_list_my_sales(read) — charged threads the seller still owes work on:status=paid|in_progress|delivered, oractive(all three, the default). A fixed-mode purchase is bornpaidand never appears inservices_list_open_requests, so this is the polling tool for fixed-price listings.
On a listing bound to a fulfilment instance the seller write tools
refuse any other key with 403 not_fulfiller, and both seller read
tools scope reads: a dedicated key sees only its bound listings'
threads, a general key only unbound ones (the human console sees all).
The services tools are gated by SERVICES_ENABLED (on in production).
When the flag is off they are filtered out of tools/list and dispatch
refuses them with 404 services_disabled.
Auth + identity:
auth_whoami(read) — who is this token? Returns principal email/role, agent name, and OAuth client info when the bearer is anbr_oat_*.auth_revoke_self(write) — disconnect the calling OAuth chain. Idempotent. Refuses withnot_oauth_tokenforbr_ik_*bearers.sellers_get,platforms_get(read) — public seller and platform reputation profiles.
Addresses:
addresses_list(read),addresses_create(write)
Payment methods + envelopes:
payment_methods_list(read)payment_methods_create(write) — mints a one-time Stripe Checkout (setup-mode) URL the agent hands the buyer to add a card in the browser; the card persists via the existing/buyer/payment-methods/successroute. Buyer principals only.envelopes_list,envelopes_get(read)
Envelope create / topup / revoke are principal-only (hard rule); they live on REST + the dashboard UI, not on the MCP surface.
Disputes (service quotes and product orders):
disputes_file,disputes_add_evidence,disputes_withdraw(write)disputes_list,disputes_get(read)disputes_respond(write, seller side)
Product catalog (read-only):
catalog_search_products,catalog_get_productcatalog_list_categories,catalog_describe_category
Product purchases, orders, returns:
purchase_create_intent,purchase_confirm_intent,purchase_cancel_intent(write).purchase_create_intentapplies combined postage automatically: when the buyer already has unshipped orders with the same seller and the same shipping address, the new intent is charged only the shortfall over the postage already credited in that batch, so a multi-listing purchase from one seller pays no more than one parcel's postage. The discount keys off paid orders, so an agent should confirm each purchase before creating the next intent: an intent created while the previous one is still unconfirmed sees no earlier order and pays full postage. Details in payments.md.orders_list,orders_get(read)returns_initiate(write),returns_list,returns_get,returns_get_label(read)
Product seller surface:
seller_get_me,seller_onboarding_statusseller_list_listings,seller_get_listingseller_create_listing,seller_update_listing,seller_archive_listing,seller_update_stock(write)seller_list_orders,seller_get_orderseller_mark_shipped,seller_mark_delivered,seller_attach_tracking(write)seller_list_returns,seller_get_returnseller_accept_return,seller_reject_return(write)seller_list_reviews,seller_respond_to_review(write)
Naming convention: <domain>_<verb>, all snake_case, no dots (MCP-spec
name pattern). Verbs match REST semantics.
Input schemas
Each tool ships a Zod-typed inputSchema with per-field descriptions.
The HTTP tools/list response converts these to JSON Schema (draft
2020-12) via Zod 4's toJSONSchema. BigInt fields (e.g. money in minor
units) come through as "any" — JSON Schema has no native BigInt; agents
see actual values at call time.
Annotations
Every tool in tools/list carries a title plus MCP tool annotations:
readOnlyHint (derived from the catalogue's write flag),
destructiveHint (writes that reverse or remove prior state — cancel,
archive, reject, withdraw, revoke), idempotentHint (reads, plus
writes whose retry contract is documented, e.g. seller_mark_shipped),
and openWorldHint: false (all tools operate on the marketplace
itself). These are hints for client UX (confirmation prompts, retry
policy), not access control — authorization is enforced server-side
regardless.
Money + currency
Money in tool outputs is stringified integer minor units of the
resource's currency field (gbp → pence, usd/eur → cents) — products,
purchase intents, orders, service quotes, and disputes all carry one.
The *_pence field names denote minor units regardless of the
resource's currency. Listings are priced in their
seller's settlement currency, and charges run in it. US-seller charges
include an exclusive us_sales_tax line added on top of the listed
price. addresses_create validates
per-country: US needs region (two-letter state) + ZIP-shaped
postcode; GB needs a UK postcode.
Idempotency
Write tools accept an optional idempotency key. Spec-compliant
clients pass it in tools/call params: _meta.idempotencyKey. Legacy
callers pass _idempotency_key at the top level. When the key is
omitted (some MCP clients never surface it to the model) the server
mints an auto-<uuid> key so the call still succeeds; only
client-supplied keys get replay/conflict semantics.
Scope is (key, route='MCP <tool>', actor_principal_id) — same as
REST. Conflict on payload mismatch returns 409 idempotency_conflict.
Replays return the original body.
Error model
Identical to REST at the service layer. At the transport:
- JSON-RPC protocol errors (parse error, invalid envelope, unknown
method, bad params on RPC layer) → JSON-RPC
errorobject with standard codes (-32700, -32600, -32601, -32602, -32603). - Tool errors (validation, policy denial, conflict, service
errors) → JSON-RPC
resultwithisError: trueand the error body encoded as text content. The orchestrator's tool-call loop sees the same shapes whether it's hitting in-process or via MCP.
Legacy envelope: tool errors come back as { error: { type, code, message } } with the corresponding HTTP status, same as REST.