writing

NC · article

What It Takes to Run a Production-Ready MCP Server

Getting an MCP server to answer in a demo takes an afternoon. Getting it to behave for strangers, clients you didn't test and a directory that counts your errors takes the decisions below. Each one comes from a connector I run in the Claude directory, checked against the current specification.

by Nic Chin14 min readMCP / AI Agents / Architecture

part of Multi-Agent Systems · 8 articles

The short answer: an MCP server is production-ready when it fails in ways the model can understand, accepts what real clients actually send, limits the operation that costs money, says what it didn’t cover, leaks nothing through its prose or links, authenticates in line with whose data it touches, and is measured by tool calls rather than connections. None of that shows up in a demo, because a demo has one client, one user and the developer’s own arguments.

The Evidence Behind This Article

Everything below comes from building and running one server, checked against the MCP specification. The server is the SystemAudit connector: a remote, read-only MCP server at systemaudit.dev/api/mcp that scans public GitHub repositories and returns a health score, ranked risks, the architecture and a count of exposed credentials. I built it in September 2026 and it is listed in Claude’s connectors directory as a Community connector. The product itself is described in the SystemAudit case study.

It is a small server: three tools, three prompts, no sign-in, no model calls. That makes it a clean place to see which problems come from MCP itself rather than from business logic. Where its choices depart from the specification, I say so.

The specification referred to throughout is revision 2026-07-28. The connector was written against 2025-06-18 and was already stateless, which is the biggest change in the new revision.

1. Decide What Counts as an Error Before a Directory Decides for You

MCP gives a tool two ways to say something went wrong. The specification is explicit about which is which:

  • Protocol errors are JSON-RPC errors for “issues with the request structure itself that models are less likely to be able to fix”: an unknown tool, a malformed request, a server error.
  • Tool execution errors go inside the result with isError: true and carry “actionable feedback that language models can use to self-correct”: API failures, input validation errors, business logic errors. Clients should show these to the model; they only may show protocol errors.

The practical rule that follows: never return a protocol error for something the model could fix by changing its arguments, because the model may never see it.

There is a second audience for errors, and it decided my design. Claude’s directory dashboard reports each connector’s error rate, counting a result with isError as a failed call, and marks a connector’s health from it. In the week of 29 September it read 37.5% on eight calls for SystemAudit. None of the three failures was a fault. One was a private repository, one a malformed name, one a scan limit. Each was something the tool had worked out correctly and reported with isError.

So I split outcomes three ways:

How the SystemAudit connector shapes each kind of outcome
OutcomeExampleShape
An answerA completed scanNormal result with findings
A conclusionPrivate repository, name that isn’t a repository, limit reachedNormal result with scanned: false, a status, the reason and what to do next. The text opens “No scan.” so it can’t be read as findings
A faultGitHub down or throttling; database down, scanner crashAn error the client and the dashboard should count. A GitHub outage is a result with isError: true and retry advice; the server’s own faults are JSON-RPC errors

Strictly, the specification lists business-logic errors among the tool execution errors that carry isError. My reading is that “this repository is private” is not an error in the tool; it is the correct answer, and calling it a failure recorded a false 37.5% against the connector. If your client relies on isError to trigger a retry, mark these as errors instead.

The connector did depart from the 2026-07-28 text in one place, fixed on 15 October. A GitHub outage or throttle was reported wrongly in two ways. With nothing cached, it came back as a conclusion: the repository “may be private, renamed or missing”, a claim about the repository drawn from GitHub’s silence. Partway through a scan, it became a JSON-RPC internal error, which the model may never see. The specification lists API failures under tool execution errors, so both now return a result with isError: true, a status of upstream_unavailable and advice to retry. If an earlier scan exists, a failure partway through serves that instead, labelled incomplete. The cost is that GitHub outages now count against the connector’s error rate, which is accurate: the call did fail.

One more detail: once the general error helper had no callers left, I deleted it with a comment saying why. A one-line helper for the shape that caused the problem is how the problem comes back. The outage fix added exactly one isError shape back, for upstream failures only, and the comment now explains why it is that narrow.

2. Validate What Clients Send, Not What Your Schema Says

Your inputSchema describes what you hope to receive. Production shows you what arrives. Two cases from this server:

  • Correct value, unexpected key. The tools take one argument, repository. Some calls arrived with the right value under repo, url or owner and name separately. Each of those became an invalid-params protocol error, which the directory counts as a failed call. The fix, committed on 15 October, accepts unambiguous aliases at the boundary. A call with no repository at all is still rejected, and its log names the argument keys it sent, never the values, so the next mismatch can be diagnosed without storing user input.
  • Valid-looking input that escapes its purpose. The URL parser accepted any two path segments, so github.com/../user would have become a request to GitHub’s /user endpoint using the server’s own token. Behind a web form that was tolerable; on a public endpoint it was not. Validation moved first to the connector boundary and then into the shared parser, so the website’s forms got the same protection.

The specification’s security section is short and worth taking literally: servers must “validate all tool inputs”, “implement proper access controls”, “rate limit tool invocations” and “sanitize tool outputs”. Two smaller habits follow from it. Cap anything you echo back (the refusal once repeated the caller’s argument verbatim, twice in the response and again in an alert), and return a message that says how to fix the input, because Claude’s review checklist fails generic errors such as “Bad Request” with no detail.

3. Write Tool Descriptions for the Model That Has to Choose Them

Connecting a server is not using it. On 28 September, eleven accounts had connected the connector and one had ever called a tool. The tool descriptions said only what each tool returned, so Claude reached for them only when someone typed “SystemAudit”.

Three changes addressed it:

  1. Each description says when to use the tool. For example: before depending on, adopting, contributing to, investing in, buying or comparing a public repository. The reach statement (public repositories only) stays in every description.
  2. Prompts give a person something to try. Three prompts appear in Claude’s menu: vet a repository, write a due-diligence read, compare two.
  3. One call answers most questions. Claude was calling all three tools for one question: three round trips for one scan. The main tool now carries the architecture and the credential counts too, and the descriptions say so. The other two tools remain for narrower questions, so nothing a user relied on disappeared.

Two limits on this. The review checklist rejects descriptions that tell Claude how to behave or promote products, so “when to use” has to describe a situation, not issue an instruction. And every tool needs a title and the right readOnlyHint or destructiveHint. Those hints set Claude’s auto-permissions, but the specification says clients must treat them as untrusted from untrusted servers. They are labels, not security.

Did it work? Over the 30 days to mid-October, the directory dashboard shows 39 accounts finished connecting and 5 made a tool call. That is better than one in eleven and still low. Adoption, not the protocol, is the hard part of a public MCP server.

4. Rate Limit the Expensive Operation, Not the Request

The first limit counted requests per IP address. It was wrong twice over.

Requests are the wrong unit. A handshake or a tool listing costs nothing. A scan that misses the cache costs around 150 calls against the GitHub token the website also depends on. So the connector limits fresh scans: three an hour per caller and ten an hour across all callers. Handshakes, tool listings and cached answers are free. A raw ceiling of 600 requests an hour remains only to stop flooding.

IP addresses are the wrong identity. Calls from Claude’s web app and Cowork arrive from Anthropic’s published outbound range, not from the person’s machine. One IP is many people, so the fourth person to scan in an hour was being told they had hit their limit. That range is now exempt from the per-caller limit and still held to the global one, which is the limit that actually protects the upstream budget.

Three details that matter more than the numbers:

  • Batches. A JSON-RPC batch is one HTTP request, so a per-request limit lets one batch start unlimited scans. Batches are capped at ten messages and one tool call, run one after another.
  • Fail closed. Without a shared counter in production, both limits refuse rather than fall back to per-instance memory, which on serverless means no limit at all.
  • Say which limit and when it lifts. The refusal names the limit, roughly when it resets, and that already-scanned repositories still answer. Nothing is offered for sale in a refusal.

5. Never Let a Partial Result Read as a Clean One

A tool that reads part of the data and reports no problems has told the model the data is clean. This connector reads a capped subset of a repository through the GitHub API, and the website had already been caught by exactly this: files that failed to fetch were skipped, and the scan then looked healthy.

Every result now carries coverage (files read out of files in the repository) and a scanStatus of complete or incomplete with a reason. When the shared token’s hourly budget is nearly spent, the connector serves the last scan labelled incomplete, or refuses. It doesn’t start a scan it can’t finish. When a repository is made private or deleted, the cached answer stops being served; only an outage falls back to the cache, and it says the answer is old.

This is the most transferable lesson in the article. Any tool that pages through an API, samples documents or times out partway should return how much it covered, in a field the model will read aloud.

6. Check What Leaks Through Prose and Links

The secrets tool returns counts by severity, never a file, line or value. Code review found that this wasn’t enough: the scanner’s plain-English risk descriptions named the files too (“2 API keys are visible in your source code (src/config.ts)”). Every risk title and description now has locations stripped, and a test runs the real scanner and the real risk builder and fails if any path, filename or key fragment reaches a result.

The second leak was in the wording, not the data. The descriptions said locations were “never returned”. True of the connector’s output, but every result links to the full report on the website, and that report shows locations for critical and high findings to anyone with the link. The tool descriptions and the privacy page now say exactly that, with a test that fails if a description makes the old claim again, and the corrected directory listing went to review on 15 October. A claim about what your server withholds has to hold for everything a caller can reach through it, links included.

If your tools read documents or web pages a third party controls, the same thinking extends to injected instructions; the controls are in prompt injection defence for RAG.

7. Choose Authentication by Whose Data the Tools Touch

Authorisation is optional in the specification, and Claude’s connector submission guidance asks for OAuth when tools act on a user’s account and allows no authentication for public data. SystemAudit has none, deliberately: everything it reads is public, and holding no GitHub account access is what makes it safe to point at anyone’s repository. Private repositories are refused with that explanation, rather than an apology.

The moment a server touches a user’s or a tenant’s data, the authorisation section applies in full. It is based on OAuth 2.1, and the security best practices add the parts most often missed:

  • Publish Protected Resource Metadata (RFC 9728), and check that every access token was issued for this server as its audience.
  • Never pass a client’s token through to an upstream API. The specification forbids accepting tokens not issued for the server; passthrough turns it into a confused deputy.
  • Don’t treat possession of a state handle as authentication. The 2026-07-28 revision removed protocol sessions, so state lives in explicit handles that must be checked against the caller on every call.
  • Enforce tenant boundaries in the data layer, not in the tool description. The same principle as in multi-tenant RAG isolation.

This is where a server goes from a developer tool to something a security review will ask about. If yours is heading to enterprise customers, the questions they send are the ones in the LLM security checklist.

8. Measure Tool Calls, Not Connections

The directory dashboard separates accounts that tried to connect, accounts that finished connecting, and accounts that made a tool call. Only the last is use. For this connector the gap between them was the most useful number it produced, because it pointed at discovery (section 3), not at the protocol.

What I log, and why each one earned its place:

  • Every tool call with its outcome: fresh, cached, refused, limited, invalid or failed. The database only records fresh scans, so without this log the cached and refused calls would be invisible.
  • Malformed calls, grouped. An automated tester once sent fifteen calls in one second with random strings. Each took its own alert and buried real use, so invalid calls now produce one grouped alert per ten minutes while every real call keeps its own.
  • Argument keys on failure, never values, for the reason in section 2.

For a standard shape, OpenTelemetry’s MCP semantic conventions (still in development) record mcp.method.name, the tool name and an error.type, which should be tool_error when a result carries isError. Their session metrics assume sessions the 2026-07-28 revision removed, so per-call counts are the measure to rely on. For anything that touches regulated data, the per-call record in what to log in an LLM audit trail applies to tool calls too.

9. Test Each Guard by Breaking It

The connector’s tests cover the handshake, tool listing, batches, notifications, each JSON-RPC error code, the privacy rules, the budget guards and both scan limits. The part that made them trustworthy: each guard was broken on purpose once, and the suite had to fail before the guard was restored. The reason is a test elsewhere in the same codebase that passed for weeks while checking nothing, because it used the same wrong data shape as the code it tested; a later audit found it. A test that has never failed has not proved anything.

Before submitting anywhere, the checks that catch what unit tests don’t:

  1. Call every tool through the MCP Inspector, including with wrong, missing and aliased arguments.
  2. Add it as a custom connector in Claude and ask questions in plain language, without naming the tool. If the model doesn’t choose it, fix the description before anything else.
  3. Time a first, uncached call on the deployed environment, not locally. A call killed by the platform’s timeout returns the platform’s HTML error page mid-protocol, not a JSON-RPC error, so set the function’s maximum duration above your slowest real call.
  4. Read the listing copy against the code. Mine quoted response times I had only measured locally, and claimed more about credential details than the linked report delivered; both came out in a listing correction.

When MCP Is the Wrong Choice

MCP earns its overhead when several AI clients you don’t control need to use the same capability: your customers’ assistants, Claude, an IDE agent. It is the wrong choice when one application of yours calls one API: a typed function call or a REST client is simpler, faster and easier to test. Many engineers also prefer a command-line tool for coding agents because a large tool catalogue consumes context. If you are weighing a managed agent platform instead, the trade-offs are in enterprise AI agent platforms, and the strategic case for MCP is in MCP explained.

A Production Checklist for Your MCP Server

  1. Conclusions are results; only faults are errors. Nothing the model could fix is a protocol error.
  2. Input is validated at the boundary, with unambiguous aliases accepted and argument keys logged on failure.
  3. Every description says when to use the tool, with a title and the right read-only or destructive hint.
  4. Limits count the expensive operation, assume shared IPs, cap batches and fail closed.
  5. Every result states its coverage, and stale or partial answers say so.
  6. Sensitive data can’t leave through prose, links or echoed input, and a test proves it.
  7. Authentication matches whose data the tools touch; tokens are audience-checked and never passed through.
  8. Tool calls are logged with their outcome; connections are not mistaken for use.
  9. Each guard has been broken once on purpose, and the listing copy matches the code.

Need an MCP server built or productionised?

I design, build and harden MCP servers and the agent integrations around them: exposing a SaaS product to Claude and other assistants, connecting AI tools to internal systems, or taking a prototype that works for one developer to something that works for your customers. Engagements are fixed-scope reviews or contract builds, and I work with teams in the UK, US and Europe. More on how I work on AI agent development.

Book a 45-minute call(opens Calendly in a new tab)

Or describe your project in the chat, or email nic.chin@nicchin.com

Frequently Asked Questions

What makes an MCP server production-ready?

Seven things a demo usually lacks: errors the model can act on, kept apart from real faults; input validated against what clients actually send; tool descriptions that tell the model when to use each tool; limits on the expensive operation rather than on requests; results that say what they did not cover; no sensitive data leaking through prose or links; and authentication that matches whose data the tools touch. Then measure tool calls, not connections, so you know whether anyone uses it.

When should an MCP tool return isError instead of a JSON-RPC error?

The MCP specification (2026-07-28) says problems with the request itself, such as an unknown tool or a malformed request, are JSON-RPC protocol errors, while API failures, input validation errors and business-logic errors are tool execution errors with isError set to true, because clients should pass those to the model so it can correct itself. Never return a protocol error for something the model could fix by changing its arguments.

Does an MCP server need OAuth?

Not always. Authorisation is optional in the MCP specification, and Claude’s connector submission guidance asks for OAuth when tools act on a user’s account and allows no authentication for public data. Once a server holds anything that belongs to a user or a tenant, it needs OAuth with audience-checked tokens, and it must never pass a client’s token through to an upstream API.

How do you rate limit a public MCP server?

Count the operation that costs you something, not HTTP requests. Handshakes and tool listings are cheap; a cache miss that calls an upstream API is not. Expect many users behind one IP address: calls from Claude’s web and desktop apps arrive from Anthropic’s published outbound range, so a tight per-IP limit cuts strangers off from each other. Pair a per-caller limit with a global one that protects the upstream budget, and cap JSON-RPC batches, because one batch is one request.

The Demo Is the Easy Part

The protocol is not what makes an MCP server hard. Error semantics, the arguments real clients send, shared IP addresses, partial results, leaks through links and the gap between connecting and using all appear only once strangers arrive and a directory starts counting. If your server touches customer data and is heading to enterprise buyers, an AI security and architecture review covers it with the rest of the system.

Ready to discuss your AI project?

Book a free 45-minute call to explore how AI can transform your business. Or if you already have a codebase, get an instant architecture report at SystemAudit.dev No technical knowledge needed, results in 3 minutes.

Or describe your project in the chat, or email nic.chin@nicchin.com

About the Author

Nic Chin is an AI Architect and Fractional CTO who helps companies design and deploy production AI systems including RAG pipelines, multi-agent systems, and AI automation platforms. He has delivered enterprise AI solutions across the UK, US, and Europe, and provides AI consulting in Malaysia and Singapore.