MCP server

Tollgate ships an MCP server at mcp/server.js that turns the marketplace into three tools any MCP-capable agent can call mid-task: search what is for sale, buy a resource over x402, and check the wallet. The agent never handles a key and never signs anything. It says "buy this URL", the server pays with the configured agent key using the custodial tollgate-credit scheme, and the content comes back into the conversation along with a receipt.

The server is about 110 lines on top of @modelcontextprotocol/sdk, speaks stdio, registers as tollgate (version 0.2.0), and reuses the same payFetch from sdk/index.js that the CLI uses. There is no separate payment logic to audit.

Configuration

Two environment variables, both read at startup:

Variable Meaning Default
TOLLGATE_URL Platform base URL. A trailing slash is stripped. https://web-production-095eb.up.railway.app
TOLLGATE_API_KEY Agent key (tg_sk_ plus 32 hex chars) used by buy and wallet_status. empty, paying is disabled

Agent keys are minted when you create an agent on the platform. See Give an agent a wallet for that flow. market_search needs no key at all, the marketplace index is public.

Registration

Claude Code, from the repository root:

claude mcp add tollgate -e TOLLGATE_API_KEY=tg_sk_your32hexchars -- node mcp/server.js

Use an absolute path to server.js if you register from anywhere other than the repo root:

claude mcp add tollgate -e TOLLGATE_API_KEY=tg_sk_your32hexchars -- node /path/to/402/mcp/server.js

Any other MCP client, the generic config JSON:

{
  "mcpServers": {
    "tollgate": {
      "command": "node",
      "args": ["/path/to/402/mcp/server.js"],
      "env": {
        "TOLLGATE_API_KEY": "tg_sk_your32hexchars",
        "TOLLGATE_URL": "https://web-production-095eb.up.railway.app"
      }
    }
  }
}

Point TOLLGATE_URL at your own deployment if you are not using the hosted one. buy refuses URLs outside this base, so the value doubles as a fence.

The three tools

market_search

Input: { query?: string }.

Fetches GET {TOLLGATE_URL}/market/index.json, the machine-readable marketplace index. If query is given, it filters case-insensitively on resource title and description. The result is one text line per resource, in this exact shape:

<title> · <price> / <unit> · <kind> · <url> · paths: <path> <path> …

For example:

Recipe corpus, 12k dishes · $0.05 / query · dataset · https://web-production-095eb.up.railway.app/r/recipes · paths: /query /today

No matches returns the text no matching resources. If the index cannot be fetched, the tool returns marketplace unreachable: <status> as an error result.

buy

Input: { url: string }, validated as a URL by the schema.

The checks and steps, in order:

  1. The URL must start with TOLLGATE_URL plus /. Anything else is refused with refusing non-Tollgate URL (expected <base>/…) before any network call. The server will not pay arbitrary internet URLs, period.
  2. TOLLGATE_API_KEY must be set, otherwise the tool returns TOLLGATE_API_KEY is not set; cannot pay.
  3. payFetch does a plain GET. A 200 on the first try means the resource is free and nothing is charged. A 402 means the server builds an X-PAYMENT header (base64 JSON with scheme: "tollgate-credit" and the agent key as payload) and retries the same URL exactly once.
  4. If the final response is 200, the tool returns the receipt line, a blank line, then the response body truncated to the first 20,000 characters:
receipt: tg_9f2e4c1a

{"query":"*","results":3,"dishes":[{"name":"Stamppot","time_min":45},…]}

For a free resource the first line is free resource, nothing charged instead.

  1. Any non-200 final status becomes an error result in this shape, with the platform's error code and, when present, a human-readable detail on the second line:
NOT DELIVERED · 402 · payment_requires_approval
price $0.4 is above the ask-me threshold $0.25; owner approval requested

wallet_status

Input: none.

Fetches GET {TOLLGATE_URL}/api/agent-status with Authorization: Bearer <key> and formats the response as a single line:

<name> · balance <balance> · spent today <spent> of <daily budget> · max/request <cap> · ask-above <threshold> · allowlist: <domains or (all)>

A missing key returns TOLLGATE_API_KEY is not set; any non-200 from the endpoint returns status unavailable: <status>. Agents call this before an expensive purchase to check headroom, or after a NOT DELIVERED to understand why.

The safety model

The design principle: the model decides what to buy, the platform decides whether the purchase is allowed. Nothing the model says can loosen the rules.

The URL fence is client-side, the rules are server-side. The MCP server refuses any URL outside TOLLGATE_URL before making a request. Everything else, every spending rule, is enforced by the payment engine on the platform when the X-PAYMENT header arrives. A modified or malicious MCP client gains nothing, because the rules never lived in the client.

The rules run in a fixed order (see settleCustodial in src/x402/engine.ts):

Order Rule Error code What happens
1 Domain allowlist domain_not_allowed Blocked, owner notified
2 Ask-above threshold payment_requires_approval Held as pending_approval, owner notified, one approval releases exactly one purchase
3 Max per request over_max_per_request Blocked, skipped when an explicit approval is being consumed
4 Daily budget daily_budget_exceeded Blocked, a hard stop even for approved purchases
5 Balance insufficient_funds Blocked at settlement

Every refused attempt is written to the payments ledger with its block reason, so the owner sees held and blocked purchases in the dashboard, not just successful ones. Retrying a held purchase does not pile up approval requests, pending_approval rows are deduplicated per agent and resource.

Errors come back as text, not exceptions. Every failure path returns an MCP error result whose content is a readable line like NOT DELIVERED · 402 · daily_budget_exceeded. Nothing is thrown, the agent's tool loop keeps running, reads the reason, and can adapt: pick a cheaper resource, call wallet_status, or tell the human it needs an approval.

The key never enters the model's context. TOLLGATE_API_KEY lives in the environment of the server process. The model sees receipts and balances, never the credential.

Worked example

The defaults for a fresh agent are max/request $0.05, daily budget $5, ask-above $0.25 (from src/migrations.ts). Here is a session where an agent named scout is asked to pull structured recipe data.

The agent searches:

tool: market_search
args: { "query": "recipe" }

Recipe corpus, 12k dishes · $0.05 / query · dataset · https://web-production-095eb.up.railway.app/r/recipes · paths: /query /today

It picks the /query path and buys. $0.05 is at the per-request cap and under the ask-above threshold, so it settles instantly:

tool: buy
args: { "url": "https://web-production-095eb.up.railway.app/r/recipes/query" }

receipt: tg_9f2e4c1a

{"query":"*","results":3,"dishes":[{"name":"Stamppot","time_min":45},{"name":"Bitterballen","time_min":90},{"name":"Erwtensoep","time_min":120}]}

It checks headroom before deciding whether to keep going:

tool: wallet_status
args: {}

scout · balance $4.95 · spent today $0.05 of $5 · max/request $0.05 · ask-above $0.25 · allowlist: *.tollgate.site

Later the agent tries something premium, a $0.40 case-law query. That is above the $0.25 ask-above threshold, so the purchase is held, the owner gets an "approval needed" notification, and the agent gets the reason instead of the content:

tool: buy
args: { "url": "https://web-production-095eb.up.railway.app/r/case-law/search" }

NOT DELIVERED · 402 · payment_requires_approval
price $0.4 is above the ask-me threshold $0.25; owner approval requested

The owner approves it from the agent's wallet page, the agent retries the same buy, and the approval releases exactly that one purchase. Nothing about the rules changed, and the agent never needed to know why the human said yes.