Building an MCP server for a paid SaaS: OAuth, cost previews and the tools we left out
Traffic Creator is one of my companies. Customers use it to send configurable, automated website visits and Google search clicks to their own sites, for analytics and website testing. This week we opened the account to A
Traffic Creator is one of my companies. Customers use it to send configurable, automated website visits and Google search clicks to their own sites, for analytics and website testing. This week we opened the account to AI assistants: you add one address to Claude or ChatGPT, sign in, and ask "how did my campaigns do yesterday?"
We already had a REST API. Building the MCP server on top of it took less code than I expected, but more decisions. This post is about those decisions, because most of them come down to one problem. An assistant that can start a campaign can also spend a customer's money.
The server is a thin proxy, on purpose
The MCP server has no database access and no state beyond a short key cache. Every tool is a small function that calls one of the /api/v1/account routes with the caller's own credential and returns the JSON.
That sounds boring, and it is the most important decision we made. Ownership checks, balance checks, rate limits (60 reads and 10 changes per minute and key), read-only scopes and the activity log all live in the API, in one place. A tool cannot do anything the customer's API key could not do, and when we fix a rule in the API, the assistants get the fix at the same moment. We deploy the API and the MCP server separately, so the server can also hide a tool whose route is not live yet instead of offering something that fails.
Tools follow questions, not tables
The obvious first version would mirror the API routes one to one. That reads like a database schema, and customers don't ask for "project analytics with a date range". They ask "which campaign is behind?" or "compare last week with the week before".
So we grouped the 34 tools by what people ask: read your numbers, rankings, look into one campaign, prices and a cost preview, create, change/pause/resume, credits and billing, support tickets, your account. Each tool description is written for the model: what it returns, which units, which time zone (UTC), and when to use a different tool instead. Assistants show tool names while they work, so the names had to make sense to a customer too: get_campaign_stats, preview_campaign, pause_campaign.
Annotations decide when the app asks
MCP lets a server describe each tool with hints: read only, destructive, idempotent and open world. We use five combinations:
- READ: read only, not destructive, idempotent.- SPEND: changes something, destructive, not idempotent.- CHANGE: changes something, destructive, idempotent.- STOP: changes something, not destructive, idempotent.The fifth, MESSAGE, is for creating or replying to a support ticket. Creating a campaign is SPEND. Resuming and updating are CHANGE: running them twice does no extra harm, but they can make a campaign spend credits again, so they are marked destructive as well. Claude, ChatGPT, Claude Code and Codex use these hints to ask the user before they call such a tool. Pausing is STOP: it changes something, but it only ever makes the account cheaper, so we don't want the app to make it feel dangerous. Hints are a request to the client, not a guarantee. So the server instructions say it in plain words as well: before creating a campaign, show the user the result of preview_campaign and get their agreement. The preview returns which wallet pays, how many credits the campaign needs and whether the balance covers it. Creating the campaign itself costs nothing; credits are charged per delivered visit or verified click, and never more than the balance. ## Don't let the assistant overwrite the dashboard People will use the dashboard and an assistant on the same campaign. If the assistant read a campaign ten minutes ago and the customer changed it in the dashboard since, a blind update would silently undo the customer's change. Every campaign carries a settings_version. update_campaign has to send back the version it read, and the API refuses the change if the campaign has moved on. The assistant then has to read the campaign again before it can try once more. This is ordinary optimistic locking, but it matters more with a model in the loop, because models are very confident about state they read a while ago. ## The tools we left out These are deliberately missing:
- deleting and cloning campaigns;- buying credits;- anything about billing, payment details, the profile or the password.Delete was the easiest call. A misunderstood sentence should never make a campaign disappear, and opening the dashboard once more costs the customer a few seconds. Payments were also easy: no card or bank details pass through the server, and the billing tools can only read purchases, refunds and the subscription. Read-only access is a choice on the consent screen. With it, the assistant can report but not change or spend anything. ## OAuth for Claude and ChatGPT, keys for terminals Claude and ChatGPT connect with OAuth, so the customer signs in with their normal account and never copies a secret. The flow goes like this:
- A request to /mcp without a token gets a 401 with a resource_metadata pointer to /.well-known/oauth-protected-resource/mcp. That points the client to our authorization server.- Clients register through Dynamic Client Registration or a client ID metadata document.- PKCE is required, S256 only.- The customer lands on a consent page in our dashboard and chooses Full access or Read only. Behind those sit four scopes: campaigns:read, campaigns:create, campaigns:update and campaigns:control.- Tokens are opaque and stored only as SHA-256 digests. Access tokens live one hour, refresh tokens thirty days and rotate on every use. Presenting an already rotated refresh token revokes the whole token family (RFC 9700, section 4.14.2).Tools on your own machine, such as Claude Code, Codex and Cursor, can sign in the same way or use an account API key as a bearer token. Changing the password, or revoking an app on the API & MCP page, ends that access at once. ## Watching it in production Every tool call logs the tool, the outcome and the kind of credential, without arguments or account data. A small monitor connects like a client every five minutes. It checks that tools/list returns exactly the number of tools we expect and calls one read tool. If a deploy drops a tool or breaks authentication, we hear about it before a customer does. ## What I would do the same way again Build on the public API instead of beside it. Write tool descriptions for a reader who has never seen your product. Treat every tool that costs money as something the user has to see before it runs. Leave the irreversible things in the UI. If you want to see what it looks like for a customer, the setup for each app is here: https://traffic-creator.com/mcp-server?utm_source=devto&utm_medium=organic_social&utm_campaign=mcp_launch If you have built an MCP server for a product that charges money, how did you handle the moment the model is about to spend it?
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.