MCP Server and Client Best Practices
Every practice on this page traces back to a real failure mode covered elsewhere in this section: a tool that never showed up because it wasn't registered, a schema change that broke an existing client, a shared server with no rate limiting that one caller could starve.
Use this as a pre-flight check before moving a server from local development to anything shared or remote.
How to Use This Checklist
- Walk it top to bottom the first time you deploy a new server, tool/resource design first, testing last.
- Revisit sections C through E (transport, auth, versioning) any time you change how or where a server is deployed.
- Not every rule applies to every server, a purely local stdio tool has no real need for authentication or rate limiting.
- Treat an unchecked item as a known gap, not a blocker, but one you should be able to explain if asked.
A - Tool, Resource, and Prompt Design
- Give every tool a clear, action-oriented name and a docstring that describes what it does and any constraints on its arguments. This is what Claude and human developers both use to decide whether and how to call it.
- Use precise type hints or schemas for every argument, including fixed value sets, rather than a loose
str. A vague type produces a schema that gives the client no real guidance on valid input. - Use a tool for anything with side effects, and a resource for anything that's purely read-only data. Mixing the two up confuses how a client presents the capability and how it reasons about safety.
- Raise real exceptions for failure cases instead of returning an error string as if it were a success. A returned string looks like a valid result to the client, an exception is converted into a proper structured error.
- Keep each server focused on a coherent set of related capabilities rather than one large catch-all surface. A server with dozens of unrelated tools is harder for both Claude and maintainers to reason about.
B - Request Lifecycle and Registration
- Verify every implemented handler is actually registered, not just written. A tool function that exists but was never decorated is invisible to any connecting client, the single most common source of "my tool doesn't show up."
- Confirm capability negotiation succeeds before assuming any tool, resource, or prompt is reachable. Nothing in the exchange stage of the lifecycle works until negotiation completes.
- Treat each tool call as independent unless you've deliberately built session-scoped state. The protocol makes no ordering guarantee between calls, relying on implicit memory across calls is a common source of subtle bugs.
- Keep per-session state separate when a server handles multiple concurrent client sessions. Mixing state between sessions is a correctness bug, not just a performance one.
C - Transport Choice
- Default to stdio for local development and single-user tools. It has no network configuration, no port to manage, and the fastest possible restart loop while a tool surface is still changing.
- Move to HTTP/SSE once more than one client, machine, or team needs to reach the same server. stdio spawns a separate process per client, which breaks down for shared state or coordinated access to a backend.
- Treat a stdio-to-HTTP/SSE migration as a transport swap, not a rewrite. Tool, resource, and prompt handlers are transport-agnostic in both official SDKs, migration is mainly about adding authentication and swapping the connection layer.
D - Authentication and Access Control
- Never expose an HTTP/SSE server without authentication, even for what feels like an internal or temporary deployment. Anything reachable over a network by parties you don't fully trust needs a boundary in front of it.
- Enforce authentication at the connection layer, before the MCP transport is reached, not inside individual tool handlers. By the time a handler runs, negotiation has already exposed the server's capability manifest.
- Use
hmac.compare_digestor an equivalent constant-time check for any secret comparison. A naive==comparison can leak timing information that helps an attacker guess a valid token. - Choose token-based auth for a small, known set of trusted clients, and OAuth for a broader or third-party audience. Match the scheme's complexity to who's actually connecting rather than defaulting to the heavier option everywhere.
- Terminate TLS in front of any HTTP/SSE deployment. Sending tokens or OAuth codes over plain HTTP exposes them to anyone observing the network path, defeating the authentication scheme entirely.
E - Rate Limiting and Production Load
- Key rate limits by resolved client identity, not a single global counter. A shared counter lets one high-volume caller exhaust the limit for every other client.
- Choose a rate limiting strategy that matches your traffic shape: fixed window for simple, steady traffic; sliding window or token bucket for bursty, legitimate traffic. A fixed window that's too rigid causes false rejections right at the reset boundary.
- Only add rate limiting once a server is genuinely shared. It adds real complexity that a single-user stdio tool doesn't need to carry.
- Return a clear, distinguishable error when a client is rate-limited, rather than an ambiguous generic failure. The caller, or Claude, needs to recognize it as a rate limit and back off, not retry blindly.
F - Schema Versioning
- Add new tool arguments as optional with sensible defaults, never rename or remove existing ones without a plan. Existing clients that call with the old shape will otherwise fail schema validation immediately.
- Ship genuinely incompatible changes as a new tool name rather than mutating the existing one. Forcing an incompatible change into the same name means every connected client has to update simultaneously.
- Give any deprecated tool or field a visible deprecation notice in its description, with a defined removal window. A silent removal breaks clients with no warning at all.
- Treat tool arguments as a public contract, the same care you'd apply to a public API, not an internal function signature. Clients you don't control are depending on the exact shape you've published.
G - Testing Before Deployment
- Write unit tests that call tool and resource handler functions directly, covering both the happy path and at least one validation failure per handler. Fast feedback for business logic without the overhead of the protocol layer.
- Write at least one integration test that spawns the real server and drives it through a client session. This is the only layer that catches a tool that's implemented correctly but never registered.
- Test that invalid input surfaces as a structured error through the real client, not just inside the unit-tested function. Confirms the failure actually reaches the client cleanly rather than crashing the server process.
- Add an integration test that exercises authentication when the server is deployed over HTTP/SSE. Valid token succeeds, missing or invalid token is rejected, both need coverage before a remote deployment.
- Re-run integration tests after any schema change to catch a regression against clients using the older argument shape. Tests double as a safety net for the versioning discipline in section F.
FAQs
Do I need to follow every rule on this checklist for a small local tool?
No. Sections A, B, and G apply to nearly every server regardless of size. Sections C through F are mostly about shared, remote deployment, a single-user stdio tool can reasonably skip authentication and rate limiting entirely.
What's the single most common mistake covered here?
Writing a tool handler and never registering it with the server's decorator. It passes any test that calls the function directly and is completely invisible to a real client, since it never appears in the capability manifest.
Why does authentication come before rate limiting in this checklist?
Rate limiting needs a resolved client identity to key its counters against. Without authentication in place first, there's no reliable way to distinguish one caller from another on a shared server.
Should I add rate limiting to a server before it has real usage?
Not necessarily. It's reasonable to defer rate limiting until a server has more than one real client, adding it prematurely is extra complexity with no immediate payoff for a single-user deployment.
How strict should schema versioning discipline be for an internal-only server?
Less strict than a public server, but not absent. Even internal clients break if an argument is renamed without warning, additive-only changes are cheap enough to be worth doing everywhere.
What's the fastest way to check that section B (registration) is actually satisfied?
Call list_tools() through a real client session and confirm every handler you've written shows up by name. This is exactly the integration test pattern covered in section G.
Is TLS really necessary for an HTTP/SSE server on a private network?
Yes, as a default. Private networks aren't always as isolated as assumed, and access requirements have a way of expanding to include external clients later, at which point missing TLS is a much bigger retrofit.
Do tools and resources need different testing approaches?
The same two-layer approach applies to both, unit test the underlying function directly, then confirm through an integration test that a real client can call the tool or read the resource and get the expected result.
What should I do if I find a tool argument that needs to be removed?
Follow section F: keep it working, mark it deprecated in the description with a removal window, and only remove it once that window has passed and known clients have had a chance to migrate.
Can this checklist be used as a code review gate before shipping a new server?
Yes, that's a reasonable use, walking sections A, B, and G during review for any new server, and C through F specifically when the deployment target is shared or remote.
Related
- How MCP Servers Handle Requests - the lifecycle behind sections A and B
- stdio vs HTTP/SSE MCP Deployment Comparison - the transport decision behind section C
- Authenticating Remote MCP Servers - full detail behind section D
- Versioning and Rate Limiting MCP Tool Schemas Reference - full detail behind sections E and F
- Testing MCP Servers Before Deployment - full detail behind section G
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the current Model Context Protocol Python/TypeScript SDKs. Model names, SDK versions, and the MCP spec move quickly - verify current specifics at platform.claude.com/docs and modelcontextprotocol.io before relying on them.