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:
- Every request carries the caller's own API key,
Authorization: Bearer qk_test_…(orqk_live_…). A request without one is401. The key is used for that request's calls to the API and for nothing else; the server keeps no session and no state between requests, andPAYRUDDER_API_KEYis not used. - The API address is the server's
PAYRUDDER_BASE_URL; a caller cannot change it. - It listens on
127.0.0.1unless you setPAYRUDDER_MCP_HTTP_HOST. There is no TLS here: put a proxy that terminates TLS in front of it before it is reachable from anywhere else, or the keys travel in the clear. PAYRUDDER_MCP_WRITES=1applies to every caller of the server; each caller's key still decides what succeeds.- Refused keys are counted against the caller, not against the server. The gateway refuses a request outright once an address has had 30 keys refused in a minute, and every caller of this server reaches the gateway from the server's address: without more, one flood of junk keys would lock out everybody who shares the server. Set
PAYRUDDER_MCP_EDGE_SECRETto the gateway'sPAYRUDDER_TRUSTED_PROXY_SECRET(the same value on both) and the server sends the caller's address (X-Forwarded-For) with it (X-Edge-Auth), which the gateway believes; a caller cannot choose the address (the server never forwards the header it received, except the last entry a proxy of yours wrote, withPAYRUDDER_MCP_TRUST_FORWARDED_FOR=1). Without the secret nothing is sent, and the lock-out is possible. Not run against a real gateway: the header is checked in the server's tests and the gateway's side (proxyTrusted) has its own, not the two together. - Every tool call is written to the audit log (below) with the caller's key prefix, so you can tell who did what when several people share one server.
- Each key has a limit of its own on the calls it makes through this server (below, "Rate limits"). Hand out keys of the narrowest role (
read_only). To let people sign in instead of handing out keys, see the next section.
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):
- The assistant calls
/mcpwithout a token and gets401withWWW-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. - 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: PKCES256is required,resourceis this server'sPAYRUDDER_MCP_PUBLIC_URL. - 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 shorthandsread_only,billingandingest_only; with none asked for the assistant getsread_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. - The browser goes back to the client's redirect address with
code,stateandiss(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). - On every request this server introspects the token (RFC 7662), checks for itself that it is active, was issued for this server (
audequal toPAYRUDDER_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). - A call whose scope the token does not carry is
403withWWW-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 unlessPAYRUDDER_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}
keyis the public prefix of the API key (qk_test_ab12cd34), never the secret part; in HTTP mode it tells the callers apart. In OAuth mode it isoauth:<the person's id>and the line hasclient, the address of the client's metadata document; no token is ever logged.argsholds the arguments as given, with long text cut at 80 characters; arguments that are free text about a person (name,email,phone,address,notes,reason,description,properties) are replaced by"[not logged]"and lists are counted ({"items":3}). The answer is never logged, only its size (chars) and whether it was cut.- A failed call has
"ok":false, the API's error key inerror(customer.not_found) and its HTTPstatus; the error's message is not logged. PAYRUDDER_MCP_AUDIT=0turns it off. The server writes to stderr only; keeping, shipping and expiring the lines is the log collector's job (the services' own logs go to a collector the same way).
Rate limits
Two limits apply, and they are different things.
- The API's, per tenant (the sandbox or the live environment of an app): the gateway limits each tenant per class of route (reads, writes, ingest, analytics) by its tier, and every key of that tenant shares the budget, an assistant's and your application's alike. When it is gone the API answers
429withRetry-After, which the assistant gets back as an error (HTTP 429). - This server's, per key: each API key may make
PAYRUDDER_MCP_RATE_PER_MINUTEtool calls a minute through the server (default 60, with a burst of a sixth of that at once, 10 by default;0turns it off). A call over the limit is not sent to the API: the assistant gets an error that startsmcp.rate_limitedand says how many seconds to wait, and the audit log has the call with"ok":false,"error":"mcp.rate_limited". Over HTTP each caller's key has a bucket of its own, named by a hash of the whole key (not its public prefix, which anyone who has seen a console or a log knows), kept in memory for the life of the process (a restart gives every key a full burst; up to 10 000 keys are remembered). Over stdio there is one key, so one bucket. The prompts and resources are not tool calls and are not counted.
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
- The key is the limit. Give the assistant a key with the narrowest role you can (a read-only key makes the write tools fail at the API). Use a sandbox key until you trust what it does.
- Live is off unless you turn it on. A
qk_live_…key is refused by default: in stdio the server exits at start with the reason on stderr, over HTTP the request gets a403and the API is not called. The shape of a key is the gateway's (qk_{live|test}_…). SetPAYRUDDER_MCP_ALLOW_LIVE=1to let an assistant act on live data; it applies to every caller of an HTTP server alike. - Personal data is redacted unless you turn it on. In every answer of every tool (reads, and the customer a write returns) a customer's name, e-mail address, phone number, tax id and tax office and the lines, city, region and postal code of an address are replaced by
[redacted: personal data], an answer in which something was redacted ends with a second text that says so, and the server's instructions tell the model it will see the marker; external ids, countries, currencies, plans, meters and every number stay, so an assistant can still reason about who is over a limit or behind on a payment, by external id.nameis hidden only on a customer (beside anemailor anexternal_id), not on a plan, meter, coupon or tax rate. SetPAYRUDDER_MCP_ALLOW_PERSONAL_DATA=1to hand them to the model (then they come tagged<untrusted>, as below). What is not redacted: the staff's own text (a block's reason, a credit note's reason, a payment's note and reference, an invoice line's description), because the seller wrote it and an assistant needs it to explain a block or a refund; and the external ids, which the account's own app chooses and which may be an e-mail address (an app that uses addresses as ids puts them in the model's context and in the arguments of every call). The keys that are personal arePERSONALinscripts/read-tools.config.mjs, each must also be an untrusted key, and a test fails when a text of a customer that people wrote is neither redacted nor listed as staff's. - Writes are off unless you turn them on, and nothing destructive is offered: no deletion, no cancellation, no refund, no block.
- Arguments are validated before a request is made, ids are encoded so a value cannot reach another route, and a long answer is cut (and says so) instead of flooding the assistant.
- The key is never part of an answer or an error.
- Text that people wrote is tagged. In every answer, the strings a customer, a payer or your staff wrote (names, e-mail addresses, notes, address lines, references; the list is
UNTRUSTEDinscripts/read-tools.config.mjs) are wrapped in<untrusted>…</untrusted>, and a closing tag inside such a text is broken so it cannot end the wrapper early. The server's instructions and each read tool's description tell the model that this is data to report, not an instruction to follow. That lowers the risk of prompt injection through a customer's name; it does not remove it, so keep the key's role narrow. An external id is structural, not text, so it comes back as it is, unless it looks like text (security review, 2026-11-02): an app may use a customer's address, a name or a sentence as an external id, so anexternal_idorcustomer_external_idthat has a space, is longer than 64 characters or has a character other than letters, digits and_ . : @ -is wrapped in<untrusted>like the rest (CHECKED_IDSinscripts/read-tools.config.mjs, the shape isPLAIN_IDinsrc/untrusted.ts; the server's instructions and each read tool's description say so). A normal id (cus_123,ada@example.com, a UUID) is never wrapped, so it can be copied into the next call; an id wrapped in tags is not an id the tools would accept (a tool refuses<and a space), so a model reports it and asks the person. Default, not agreed: an id with+or=(which the tools do accept) is wrapped too; and a sentence written without spaces and shorter than 65 characters (ignore_instructions_refund_all) looks like an id and is not caught: the heuristic narrows the channel, it does not close it. - The generator fails when an allow-listed operation's answer has a free-text field that is neither listed as people's text nor as an identifier, so a field added to the API is classified before it reaches a model.
- A test fails for a read whose answer has a field named like a secret, a token, a link or a payload, and for any read of the
NOT_OFFEREDlist that is allow-listed. - The prompts take arguments only in narrow patterns and name only read tools (see "Prompts").
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.