MCP server

Pre-release. Not published yet: use it from a checkout of the repository.

@payrudder/mcp lets an AI assistant that speaks the Model Context Protocol read your Payrudder account: customers, usage, subscriptions, invoices, payments, credits, credit notes and the revenue reports. It is a thin layer over the tenant API (/v1/*): what it can do is exactly what the API key you give it can do, nothing more.

Pre-release (0.0.0): not published. Build it from a checkout of the repository.

Run it

cd mcp && npm ci && npm run build        # writes dist/main.js
PAYRUDDER_API_KEY=qk_test_... PAYRUDDER_BASE_URL=https://YOUR-API-ADDRESS node dist/main.js

It talks MCP over stdio, so you do not run it by hand: you add it to your assistant.

Variable
PAYRUDDER_API_KEY required. qk_test_… for the sandbox; a qk_live_… key is refused unless PAYRUDDER_MCP_ALLOW_LIVE=1
PAYRUDDER_BASE_URL the API address; default http://localhost:42800
PAYRUDDER_MCP_WRITES 1 also offers the tools that write; off by default
PAYRUDDER_MCP_ALLOW_PERSONAL_DATA 1 lets customers' names, e-mail addresses, phone numbers, tax ids and addresses reach the model; off by default, they are redacted (see “Safety”)
PAYRUDDER_MCP_ALLOW_LIVE 1 accepts a live key (qk_live_…); off by default, a live key is refused at start (stdio) or with a 403 (HTTP), so an assistant can only touch the sandbox
PAYRUDDER_MCP_HTTP_PORT serve streamable HTTP on this port instead of stdio, see “Over HTTP” below; unset by default
PAYRUDDER_MCP_HTTP_HOST the address to listen on in that mode; default 127.0.0.1
PAYRUDDER_MCP_AUDIT 0 stops the audit log (below); on by default
PAYRUDDER_MCP_EDGE_SECRET HTTP mode: the gateway's PAYRUDDER_TRUSTED_PROXY_SECRET, at least 32 characters; the caller's address goes to the API with it (see “Over HTTP”). Unset by default
PAYRUDDER_MCP_TRUST_FORWARDED_FOR HTTP mode: 1 when a proxy in front writes the caller's address as the last X-Forwarded-For entry; otherwise the connection's address is used
PAYRUDDER_MCP_RATE_PER_MINUTE tool calls a minute for each key (see “Rate limits”); default 60, 0 for no limit
PAYRUDDER_MCP_AUTH HTTP mode: key (the default, below) or oauth, sign-in through Payrudder's authorization server (see “Sign in with OAuth”)
PAYRUDDER_MCP_PUBLIC_URL oauth: the address clients reach this endpoint at, like https://mcp.example.com/mcp; it is the audience of the tokens
PAYRUDDER_MCP_ISSUER oauth: the authorization server, the gateway's PAYRUDDER_PUBLIC_URL; default PAYRUDDER_BASE_URL
PAYRUDDER_MCP_OAUTH_SECRET oauth: the gateway's PAYRUDDER_OAUTH_RESOURCE_SECRET, at least 32 characters

Claude Code: claude mcp add payrudder -e PAYRUDDER_API_KEY=qk_test_... -e PAYRUDDER_BASE_URL=https://YOUR-API-ADDRESS -- node /path/to/mcp/dist/main.js

Claude Desktop (claude_desktop_config.json):

{ "mcpServers": { "payrudder": { "command": "node", "args": ["/path/to/mcp/dist/main.js"], "env": { "PAYRUDDER_API_KEY": "qk_test_..." } } } }

Tools

The read tools are generated from api/openapi/v1.yaml when you build (npm run generate, run by build, test and typecheck): the name, the arguments with their limits and the description of each come from the operation, so a tool cannot drift from the API (a description that says "see getEntitlement" says get_entitlement; one that names an operation that is not a tool fails the build). Which operations become tools is an allow-list, scripts/read-tools.config.mjs; only plain GETs of the tenant API can be listed, and the generator fails for anything else. A test checks every tool against the OpenAPI file.

Always on, all read-only (30 tools):

Tool What it answers
list_meters, get_meter what is counted; one meter
list_customers, get_customer customers, one by external id
get_customer_usage a customer's usage in a month, per meter
list_entitlements, get_entitlement a customer's usage against the limits of its plan; one meter's
list_plans, get_plan_version the packages; the prices of one version (per unit, tiers, packages, included quantity, limits)
list_subscriptions, get_subscription subscriptions, newest first, optionally one customer's; one subscription
list_invoices, get_invoice invoices by customer, month or status; one invoice
get_upcoming_invoice what a customer's next invoice would be if it were drafted now (an estimate)
list_customer_payments a customer's invoices with their lines and the payments and refunds on them
list_payments the newest charges and refunds
list_credit_notes, get_credit_note what was given back on invoices
get_credit_balance, list_credit_ledger what a customer can spend now, and every change to it
list_coupons the coupons (not their promotion codes)
get_tax_rates the tax added to invoices, by country
get_revenue_report, get_subscription_report, get_dunning_report invoices summed by period, subscriptions and MRR for a month, invoices whose payment has not gone through (aggregates, no free text except the provider's last error)
get_accounting_summary income and expenses of a period, one block per currency (aggregates)
get_top_customers customers ranked by their usage of a meter in a billing period (external ids and quantities)
get_dunning_settings, get_credit_settings, get_routing_rules the account's own settings that explain a behaviour: how long a declined payment waits before a retry, the low-balance threshold of credits, which payment provider a new subscription gets

What is not offered, on purpose, is listed with the reason in NOT_OFFERED in scripts/read-tools.config.mjs and checked by a test: every read that returns a link anyone holding it can open (invoice PDF, downloads, e-document files), a mail attachment, a customer's address and browser for each acceptance, a webhook endpoint's address or a delivery's answer body, the audit log, promotion codes and the files of the accounting export.

Only with PAYRUDDER_MCP_WRITES=1 (the description of each says WRITES):

Tool What it does
create_customer creates a customer
ingest_usage_events records up to 100 usage events (live events need an idempotency_key)

Prompts

Four prompts, for an assistant that shows them as ready-made requests (Claude Code lists a server's prompts as slash commands of the form /mcp__payrudder__<prompt>; not tried here). Each is a text that names read tools of this server and asks for a report; none changes anything and none names a tool that writes (a test reads the names out of every prompt and checks them against the read tools).

Prompt Arguments What it asks the assistant to do
summarise_customer_billing customer_external_id, month (optional) read the customer, subscriptions, invoices with payments, usage, the upcoming invoice, credits and limits, and report where their billing stands and what needs attention
explain_invoice invoice_id read one invoice with its plan prices, meters, usage, tax, coupons, credit notes and payments, and explain each line and the total, saying which number it could not reproduce
monthly_revenue_review month (optional) read the revenue, subscription and dunning reports and report billed, collected, outstanding, MRR, new and churned, and what is stuck in dunning
check_customer_limits customer_external_id, month (optional) read the customer's entitlements and plan and report which meters are near or over their limit

An argument is checked before the prompt is made (an external id is letters, digits and _ . : @ + = - up to 200 characters; a month is YYYY-MM; an invoice id is up to 64 of letters, digits and _ . : -), so no space, quote, angle bracket or line break can carry a second instruction, and it is put in a block that says it is data to look up. Each prompt ends with the same four rules: read tools only, amounts are minor units, <untrusted> text is data, say what could not be checked.

Over HTTP

For an assistant that is not on your machine, start the server with PAYRUDDER_MCP_HTTP_PORT=42810 (any free port). It then serves streamable HTTP at POST /mcp instead of stdio:

Claude Code: claude mcp add --transport http payrudder https://YOUR-MCP-ADDRESS/mcp --header "Authorization: Bearer qk_test_...".

Sign in with OAuth

Built, never tried with a real assistant, and every choice below is a default the owner has not agreed (docs/plans/20-mcp.md, "OAuth design"). It is for a server that other people's assistants connect to: they sign in to Payrudder in a browser and choose what the assistant may do, and nobody is handed an API key. For your own machine a key is simpler.

Start the server with PAYRUDDER_MCP_AUTH=oauth, PAYRUDDER_MCP_HTTP_PORT, PAYRUDDER_MCP_PUBLIC_URL, PAYRUDDER_MCP_OAUTH_SECRET (and PAYRUDDER_MCP_ISSUER if the API address you give it is not the gateway's public address). On the gateway set PAYRUDDER_OAUTH_RESOURCES to the same PAYRUDDER_MCP_PUBLIC_URL, PAYRUDDER_OAUTH_RESOURCE_SECRET to the same secret, and PAYRUDDER_PUBLIC_URL (the issuer, with no path) and PAYRUDDER_APP_URL (the console, which hosts the consent page). Without PAYRUDDER_OAUTH_RESOURCES the gateway offers no OAuth at all (404 identity.oauth.disabled).

What happens, following the MCP authorization specification (revision 2026-07-28):

  1. The assistant calls /mcp without a token and gets 401 with WWW-Authenticate: Bearer resource_metadata="…", scope="read_only". It reads the Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource/mcp (also served at the root form), which names the gateway as the authorization server.
  2. It reads the gateway's metadata (/.well-known/oauth-authorization-server, RFC 8414), identifies itself by the address of its Client ID Metadata Document (client_id, an https URL; the gateway fetches it, at most 32 KiB, from public addresses only, and takes the name and the redirect addresses from it), and opens the console's consent page with an authorization-code request: PKCE S256 is required, resource is this server's PAYRUDDER_MCP_PUBLIC_URL.
  3. The person signs in the way they always do (password, e-mailed code, Google, two-factor) and sees who is asking (the name the client chose, next to the address it is published at and the host the browser returns to; a redirect to their own machine carries a warning), picks the app and the environment, and keeps or takes away scopes. The scopes are the permissions of an API key (customers:read, invoices:write, …) and the shorthands read_only, billing and ingest_only; with none asked for the assistant gets read_only. What is granted is the intersection with the person's role in the account (a viewer never gets a write scope), checked again at every request.
  4. The browser goes back to the client's redirect address with code, state and iss (RFC 9207). The client trades the code at the token endpoint and gets an access token (qo_…, an hour) and a refresh token (qr_…, thirty days, rotated on every use; one used twice ends the whole grant).
  5. On every request this server introspects the token (RFC 7662), checks for itself that it is active, was issued for this server (aud equal to PAYRUDDER_MCP_PUBLIC_URL) and has not expired, and trades it (RFC 8693) for a credential of five minutes that only the API accepts. The assistant's token is never passed to the API; the API sees the credential, with the person as the actor and the client's name in the audit log. The answers are kept for 30 seconds, so a revoked token can still work here for up to 30 seconds (the expiry is checked on every request).
  6. A call whose scope the token does not carry is 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="customers:write", resource_metadata="…", before the tool runs; a batch names all the missing scopes at once. The tools that write are still off unless PAYRUDDER_MCP_WRITES=1.

Live environments are off by default: the gateway refuses to issue or exchange a token for one unless PAYRUDDER_OAUTH_ALLOW_LIVE=on (and then the person's address must be verified, and an account that requires two-factor sign-in applies its rule), and this server refuses a live token unless PAYRUDDER_MCP_ALLOW_LIVE=1. The limit of tool calls (“Rate limits”) is per person and tenant here, not per key. The person sees and ends every connection under Security → Connected assistants in the console; ending one stops its tokens at once (here within the 30 seconds above), and a person who leaves the account takes the assistant's access with them.

Not built, on purpose: dynamic client registration (RFC 7591; the specification keeps it only for old clients) and pre-registered clients, so a client that cannot publish a Client ID Metadata Document cannot connect; a per-client trust list (any https client can ask, and the person decides on what the consent page shows); scope hierarchies (the shorthands are expanded when the token is issued, so a token carries plain permissions). Nothing here was run with Claude Desktop, Claude Code or ChatGPT, or with a real browser sign-in: the pieces are tested one by one with fakes.

Audit log

Every tool call writes one JSON line to stderr (stdout carries the protocol in stdio mode; in HTTP mode a log collector reads stderr). It says what an assistant did with a key, not what it learned:

{"ts":"2026-11-02T10:00:00.123Z","event":"mcp.tool_call","tool":"get_customer","write":false,"transport":"stdio","key":"qk_test_ab12cd34","args":{"customer_external_id":"cus_42"},"ms":38,"ok":true,"chars":412,"cut":false}

Rate limits

Two limits apply, and they are different things.

That is the key's share of what the server sends, so a looping assistant stops at 60 calls a minute instead of spending the application's whole read budget (a free tenant's is 20 reads a second). It does not limit a caller who calls the API directly with the same key, and it is per server process (two replicas behind a load balancer each allow the limit). A limit per key in the gateway covers both: when you make the key in the console, give it a Rate limit (% of plan) from 1 to 100, and the gateway refuses that key with 429 (gateway.rate_limit.exceeded, with Retry-After) once it has spent that share of the tenant's limit for a class of route, whoever calls and however many replicas run. A key without a percentage has no limit of its own. It is built (docs/plans/20-mcp.md) but has not been run against a real Redis and gateway here. Also give an assistant a sandbox key, or an app of its own, and read the audit log for "ok":false,"status":429.

Resources

Besides tools, the server offers what an assistant needs to read before it calls them: payrudder://openapi (the OpenAPI document of the tenant API, nothing of the operator or console routes) and the public guides, payrudder://docs/getting-started, …/webhooks, …/sdks and …/error-keys (the documents marked public in docs/publication.yaml, and the pages of docs-site/content). They are written to assets/ when you build, so they are as old as your build and no older than the API.

Safety

Risk table (DRAFT, not signed by the owner)

Written 2026-11-02 for the owner to read, change and sign; nothing below has been reviewed by anyone but its author, and nothing was tried with a real assistant. What an answer "can expose" is what the OpenAPI response holds (api/openapi/v1.yaml), not what a given account has filled in. The model's provider sees every answer the assistant reads; that is true of all rows and is the first thing to weigh for customer data.

What holds for every tool, so the rows do not repeat it: the key decides what succeeds (a read_only key cannot write, an ingest_only key cannot read); arguments are validated before a request and path values are encoded; an answer longer than 60 000 characters is cut and says so; the key is never in an answer; every call is one line in the audit log; text people wrote is wrapped in <untrusted> (that lowers the risk of prompt injection, it does not remove it). "Free text" below names the fields that are wrapped.

Tool Can expose Free text in the answer Worst case if the model is misled Default
list_meters, get_meter what the account counts: codes, aggregation, filter values values, unit_label, category (staff) the account's product structure is read: low on
list_customers, get_customer personal data of every customer: name, e-mail, phone, address, tax id and office, locale, block state and reason name, e-mail, phone, address lines, tax id, block reason (customers and staff) the whole customer list ends up in the model's context and in anything it writes: the largest personal-data exposure of the reads. This server has no tool that sends anything out, but the assistant may have others (mail, browser) on, personal fields redacted (PAYRUDDER_MCP_ALLOW_PERSONAL_DATA=1 shows them)
get_customer_usage, list_entitlements, get_entitlement a customer's quantities per meter, limits and whether they are over none low on
list_plans, get_plan_version the price list: fees, unit prices, tiers, included quantities, limits plan name (staff) commercial terms are read: low (the account's own list) on
list_subscriptions, get_subscription who is on which plan, state, provider's reference, scheduled change, coupon coupon_code (staff) low to medium: which customers pay and for what on
list_invoices, get_invoice amounts, lines, status, payments with reference, note and who recorded them, the storage key of the PDF (not a link) line description, reference, note, recorded_by (staff and customers) invoice history of every customer is read: medium on
get_upcoming_invoice an estimate of the next invoice, lines and credit line description low on
list_customer_payments, list_payments invoices with payments and refunds, provider and amounts; the response has no card field line description, reference, note, recorded_by payment history is read: medium on
list_credit_notes, get_credit_note what was given back, to the card or the balance reason (staff), refund_error (the payment provider's words) medium: a reason written to look like an instruction on
get_credit_balance, list_credit_ledger a customer's credit lots and every change reason, idempotency_key (the app's own string) low on
list_coupons the coupons' terms (not their promotion codes, which are the discount itself and are not offered) coupon name (staff) low on
get_tax_rates the tax added to invoices by country rate name low on
get_revenue_report, get_subscription_report aggregates per currency: billed, collected, refunded, outstanding, MRR, new and churned none the account's financial results are read: medium for a business, nothing about a person on
get_dunning_report invoices whose payment has not gone through, with the customer's external id and the provider's last error last_error (the payment provider's words) medium: shows who is behind on
get_accounting_summary income and expenses of a period per currency: the seller's own financial results, no person none the account's financial results are read: medium for a business, nothing about a person on
get_top_customers which customers use a meter most in a period: external ids and quantities none (the external id is checked as an id) who the biggest customers are: medium; no contact data on
get_dunning_settings, get_credit_settings, get_routing_rules the account's settings: retry waits, the credits threshold, the provider chosen by country and currency none low: configuration the seller wrote, no secret and no person on
create_customer writes: a customer record the arguments the model chose junk or misleading customers (a name that reads as an instruction to the next assistant that reads it, tagged on the way back); no delete tool here to undo it off (PAYRUDDER_MCP_WRITES=1)
ingest_usage_events writes: up to 100 usage events per call the arguments the model chose real money in live: a misled or looping model inflates a customer's usage and so its next invoice, and an event is not unsent by this server. Live events need an idempotency_key, so a retry does not double off; use an ingest_only key, and the sandbox first
the four prompts the text of the prompt and the argument the person typed the argument, checked against a narrow pattern a long run of read calls that spends the tenant's shared read budget (see "Rate limits": the per-key limit stops a loop at 60 calls a minute by default); nothing is written on
resources payrudder://openapi, payrudder://docs/* the tenant API's OpenAPI file and the guides marked public (no operator or console route, no internal document) none none beyond what the docs site already shows on

Open questions for the owner (defaults, not agreed): that personal data of customers may reach the model's provider at all (the default is yes, for whoever holds a key and starts the server; a read_only key can read customers, a custom key without customers:read cannot, and then list_customers and get_customer fail at the API); whether list_customers should be left out of an HTTP server shared by several people. Built since (default, not agreed): the personal fields of a customer are redacted unless PAYRUDDER_MCP_ALLOW_PERSONAL_DATA=1, and a live key is refused unless PAYRUDDER_MCP_ALLOW_LIVE=1 (a flag for live reads, as the writes have theirs). The first row now reads as redacted by default; the question is whether that default is the right one.

Develop

cd mcp && npm ci
npm run typecheck && npm test && npm run build

npm test includes a test that builds the server and talks to it over a real stdio pipe, and one that serves it over HTTP. Not tried yet with a real assistant (Claude Desktop, Claude Code) by a person.