MCP server design: tools, authentication, and rate limits
By Flavio
Tagged: AI
How to design a useful MCP interface for your product, with clear tools, account permissions, usage limits, predictable results, and safe writes.
An AI agent can use your product if you give it a useful way in.
That sounds like a small integration task: expose a few API calls through MCP, add a connection button, and let people work from their favorite assistant. But the choices you make here shape the product. What can someone get done without opening your dashboard? Which account pays for a generated image? Can an assistant publish something while its user is away? How does it recover when a request times out?
I think of an agent interface as another product interface. Someone will use it to do real work, and they need clear results and predictable limits, just like someone clicking through the website.
I’ve built one of these myself, the Registrar MCP server in my Cloudflare Domains Toolkit. It searches for domains, quotes the real Cloudflare price, and buys one after I approve the exact charge. I used it to buy hostingpicker.dev for $12.20. I’ll come back to it where it made a design decision concrete.
Start with a job someone wants done
Imagine a service that generates social preview images.
On the website, a person picks a template, enters a title, adjusts the layout, and downloads an image. An agent could use the same service while preparing a website launch, since it already knows the page title and where the image goes. A useful request would be: create a preview image for this page with the account’s usual template, then return the image URL.
Compare that with exposing every database table through a generic query tool. The agent gets plenty of power, but it has to rebuild the product’s rules on every request, and those rules get harder to enforce.
For this service I’d start with listing templates and generating previews, and leave billing administration and account deletion out of the first integration.
If MCP is new to you, my introduction to the Model Context Protocol explains how an assistant, its client, and your server connect. The protocol gives them a shared language, and you still decide what your product says in it.
Design tools around completed actions
A tool called generate_preview tells the client what it’s for. A tool called execute_action with a big string argument leaves it guessing.
I’d have the generation tool accept a template identifier, a title, and an optional subtitle, and return an asset identifier, a URL, and the generation status. The account comes from the authenticated connection. An account identifier passed in the arguments must never count as proof that the caller owns that account.
Keep the tool description specific. Say which fields are required, whether generation spends credits, and whether the call creates a persistent asset, so the assistant doesn’t learn those details by making expensive mistakes.
The Registrar server has four tools: search for domain ideas, check one exact domain, create a short-lived quote, and purchase one quoted domain after approval. DNS lives in a separate cf-domains CLI with its own token, so the Registrar token can’t edit DNS and the DNS token can’t buy domains. Search accepts loose input because it’s for discovery, while quote and purchase only accept one exact domain like hostingpicker.dev.
The MCP tools specification has input schemas and structured results for expressing these contracts, plus behavioral annotations. Annotations help clients understand tools, but they’re descriptions, not enforcement. Marking a tool read-only doesn’t stop its implementation from writing to a database, so your code has to keep the promise.
Reuse the product’s business rules
The website and the MCP server should reach the same application operation.
For our image service, that operation checks the subscription, verifies access to the template, applies the title length limit, and reserves the credits it needs. Then it creates the generation job.
If you copy that logic into a second implementation for MCP, it’ll drift. Eventually the website rejects something the agent interface allows, or the agent interface charges differently.
I’d put the product rules behind a shared service function. The web route handles HTTP, the MCP handler translates tool arguments and results, and both call the same operation. A test of credit reservation then protects both interfaces, and transport tests can focus on turning invalid arguments or expired authentication into a clear response.
Authentication identifies the connection
For a remote integration, users need a way to connect their own accounts and revoke access later.
The MCP authorization specification describes an OAuth-based flow for HTTP transports. A local server that talks over standard input and output is set up differently, so don’t copy remote HTTP assumptions into every MCP deployment. My Registrar server runs over stdio, with its own narrowly scoped Cloudflare token.
From the user’s side, the connection should answer one question: which account am I giving this assistant access to? Show that account during setup, list the connection in the product’s settings, and let the user revoke it without digging through the assistant’s configuration.
Then check authorization again inside each operation. A valid token doesn’t grant access to every workspace or every paid feature, and a collaborator who can view an image library might still not be allowed to spend the team’s credits.
I’d avoid one organization-wide credential shared by everyone. It makes revocation harder and erases the difference between roles, while per-user access tells you who requested a change.
Put cost limits where the work happens
A generation tool can receive valid requests and still create an expensive problem. An agent might retry an operation, loop over a large input, or misunderstand how many variants the user wanted, and none of that needs a malicious user.
For the image service, I’d enforce a maximum output count per request and a limit on concurrent jobs, plus an account usage budget that applies across the website and the agent interface.
Each limit solves a different problem. A request rate limit controls how fast callers arrive, a credit budget controls spending, and a concurrency limit protects the generation workers. Rate limiting alone won’t protect an expensive endpoint that accepts a huge batch.
The Registrar server has a hard $20 limit on the complete initial charge. Some domain extensions require more than one year, so the server adds the renewal price for the remaining required years before comparing, and a domain advertised under $20 can still be rejected. It works in integer cents and rejects premium domains and non-USD prices.
When a client hits a limit, give it a useful error. Say whether it can retry later, reduce the batch, or ask the user to raise the account’s allowance. A vague failure encourages the assistant to try the same thing again.
Reserve credits before starting expensive work
Say an account has one credit left. A person requests an image from the website while their assistant requests another through MCP.
If both operations read the balance before either changes it, both see permission to proceed, even inside a shared function, when the check and the reservation happen separately.
I’d lock the account balance while checking it, then reserve the credit and create the operation in the same transaction, so a second request waits before checking what’s left. PostgreSQL’s row-locking documentation describes one way to coordinate writes to the same account row. The mechanism depends on your database, and the rule doesn’t: concurrent requests share the same remaining allowance.
I hit a version of this in the Registrar server. My first version marked a quote as used after waiting for approval, so two purchase calls arriving close together could both reach the approval step and show two forms for one quote. Now the quote is consumed before the asynchronous approval starts, and a test opens one approval, starts a second purchase with the same quote, and checks that the second call fails immediately.
The reservation also needs a lifecycle. I’d keep the credit reserved while the job runs, consume it when generation succeeds, and release it after a confirmed failure if that matches the refund policy. Users should see pending usage separately from completed charges.
Keep the reservation until the generator confirms the outcome, because a timeout can happen while the work continues. Returning the credit right away could let another request spend it while the first image finishes.
Then there’s the gap between saving a job and sending it to a queue. If the process stops there, the database holds a reservation for work no worker received. I’d keep enough durable job state for a background check to find and resubmit that operation, and the worker must recognize an operation it has already seen.
Retries need a product decision
Say the server creates an image, charges a credit, and sends the response, and the network drops before the client gets it. If retrying creates another image and charges another credit, the user pays for a connection problem, and agents make it easy to repeat an action while trying to recover.
For billable writes, I’d support a request identifier with idempotency semantics. The server records the operation against that identifier, and repeating the same intended request returns the existing operation instead of creating another.
Define the details carefully. The identifier belongs to an account and one specific request, reusing it with different arguments returns a conflict, its retention period is documented, and the storage behind it handles concurrent requests. An in-memory map inside one server process won’t protect a distributed service.
When the API you call has no safe idempotency mechanism, don’t retry at all. The Registrar server never retries a registration. If the connection fails after the request reaches Cloudflare, it can’t know whether the domain was bought, so the quote stays used and I have to check the Cloudflare account before trying again.
Long operations also need a way to check their status. Return a job identifier as soon as the job exists, then let the agent inspect it, which is what MCP Tasks give you. A timeout should leave the client with a way to recover.
Approval should describe the exact effect
Generating a private draft image is one action, and publishing it to a live website is another. I’d separate them: the agent prepares the asset and shows it to the user, and publishing is a distinct operation with a clear destination.
For destructive or billable operations, the approval needs enough detail to decide: the account, the affected resource, and the price. A general instruction to help with a project is no substitute for approving one specific irreversible purchase.
That’s how the Registrar server works. Before buying, it checks that the caller repeated the quote ID, domain, currency, and total cents, then asks Cloudflare for availability and price again and stops if anything changed. Only then does it show an approval form through MCP elicitation, with the domain, the exact USD total, the term, a non-refundable warning, and a checkbox that defaults to false. If the client doesn’t support elicitation, the purchase is blocked, with no terminal fallback.
Be honest about where approval is enforced. A confirmation sentence in a tool description is only guidance, so sensitive operations need a boundary in the application that the model can’t talk its way past.
Tool results can contain hostile text
Our image service might eventually accept a URL and extract a page title. That page is outside our control, and its text could contain instructions aimed at the assistant. A template description written by another user could do the same.
Return only what the task needs. Fetching a title doesn’t require returning the whole page, every embedded script, and all the metadata, and less content means less exposure and less confusion.
For server-side URL fetching, I’d also restrict network access. An arbitrary URL field can become a way to reach internal services if you don’t validate destinations and redirects.
The MCP security guidance covers trust boundaries including token handling and server-side request forgery. Passing a token through to an unrelated upstream service is especially dangerous because it blurs who the token was issued for. These protections go in the server code, whatever the assistant’s instructions say.
Make the integration easy to operate
When a user reports a failed generation, support should be able to find it.
Record the authenticated actor, the requested operation, the affected resource, and the outcome. Give the client an operation identifier it can include in a support request. Don’t log access tokens or copy sensitive input into every diagnostic message.
I’d track completed jobs and rejected requests separately, because a connection count says little about whether people can finish their work.
Tool changes need care too. Renaming an input or changing its meaning can break workflows people have saved, so keep old behavior stable and add new capabilities explicitly.
My first release would stay narrow. I’d connect one client, generate one asset, test a failed request, revoke the connection, and confirm the next request fails correctly. Then duplicate requests and exhausted credits. With the Registrar server, I kept all live testing read-only until the local policy tests passed, and ran the one real purchase last.
After that, I’d watch which tasks users keep going back to the website to finish. Those gaps tell me which tool to add next.
Want me to talk about your product? You can sponsor this site.
Related posts about ai: