How to Build a Good MCP Tool: Lessons From Running 50+ Servers in Production
I've spent the last several months building and maintaining Model Context Protocol (MCP) servers at scale — not one or two toy examples, but a working fleet of over fifty independent servers covering everything from spreadsheet automation to SQL query tools to AI image generation. That's given me something most MCP writing doesn't have: a large enough sample size to see which patterns actually hold up and which ones quietly rot.
This isn't a "getting started with MCP" tutorial — the official docs already do that well. This is the guide I wish I'd had before building server number thirty: a practical walkthrough of how to structure a tool, handle auth, validate input, manage errors, and avoid the specific mistakes that are easy to make once and then copy-paste forty more times.
Every pattern here — good and bad — is something I've actually seen happen. I've anonymized specific business contexts where they're not relevant to the technical lesson, because the point isn't which client's data was involved; it's what the code did and why.
The shape of an MCP tool, and why most of them look the same
If you've looked at more than a handful of MCP servers, you've probably noticed they converge on a similar shape almost immediately, regardless of who wrote them:
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
load_dotenv()
mcp = FastMCP("your-tool-name", instructions="...")
@mcp.tool()
def do_something(param: str) -> str:
"""One-line description — this becomes the tool's description for the calling model."""
...
return result
if __name__ == "__main__":
mcp.run()That convergence isn't an accident, and it isn't really about MCP itself — it's about FastMCP's decorator model doing exactly what a good framework should do: making the easy path also the structurally correct one. A function becomes a callable tool, its type hints become a JSON schema, its docstring becomes documentation the calling model reads at decision time. There's very little ceremony between "I wrote a Python function" and "an LLM can call this."
The problem isn't the shape. The problem is what happens inside that shape once real-world requirements show up: authentication, error handling, input coming from a caller you don't fully trust, external services that time out or throw errors, and — the underrated one — schemas that need to actually communicate intent to a model, not just satisfy a type checker.
Across a large enough number of these servers, you start to see a spectrum. At one end: a handful of genuinely well-engineered tools with real exception hierarchies, SSRF protection, and schema-level documentation. At the other end: a much larger group where the tool "works" in the sense that it returns a response, but leaks raw exception text to the caller, trusts user-supplied URLs and file paths without checking them, and documents its parameters with nothing more than a bare str or int.
This guide is mostly about closing that gap — using the good examples as the template, and the recurring mistakes as the cautionary notes.
Choosing an SDK (and a subtle trap in how you start your server)
There are two real options for building an MCP server in Python today: the official SDK (mcp.server.fastmcp, from the mcp package) and a third-party convenience layer (the fastmcp package, a separate community project). Both give you what looks like an identical surface — the same FastMCP(...) constructor, the same @mcp.tool() decorator. This similarity is exactly why teams end up mixing both across a codebase without much friction, and exactly why it's worth understanding where they actually diverge.
At the tool-definition level, they're genuinely interchangeable. Where they diverge — in a way that will bite you if you don't check for it — is server startup.
The official SDK's run() method only accepts transport and mount_path. If you want to control host and port, you have to set them beforehand on a separate settings object:
mcp.settings.host = "0.0.0.0"
mcp.settings.port = 8080
mcp.run(transport="sse")The third-party package's run() accepts host and port directly as keyword arguments:
mcp.run(transport="sse", host="0.0.0.0", port=8080)Pass host/port directly to the official SDK's run() and you'll get a TypeError for an unexpected keyword argument. It's a small thing, but it's exactly the kind of small thing that costs you twenty minutes of confused debugging the first time you copy a startup snippet from the wrong kind of server into the other.
My recommendation: pick one SDK for your team, deliberately, and write down why. If you don't have a strong reason to prefer the third-party package's ergonomics, default to the official SDK — it's the reference implementation, and I've seen review processes (human or AI-assisted) start pushing back on non-standard server construction once a codebase has enough tools built on it. Consistency here isn't about which is "better" so much as removing an entire class of copy-paste bugs.
One more thing worth checking if you're pinning versions: the official SDK's upcoming major version reportedly relocates FastMCP out of its current import path. If you're building for the long term, pin defensively (mcp>=1.0.0,<2) and revisit deliberately rather than getting surprised by an automatic dependency bump.
Designing input schemas an LLM can actually use correctly
Here's something that's easy to underweight when you're first building MCP tools: the model calling your tool never sees your Python source code. It sees the JSON schema FastMCP generates from your type hints, and nothing else. That schema is the entire interface contract between your carefully-written function and the model trying to use it correctly on the first attempt.
Compare these two versions of the same parameter:
# Version A — technically correct, minimally useful to the caller
def search(query: str, limit: int = 10) -> str:
...
# Version B — same logic, but the schema now teaches the model how to use it
def search(
query: Annotated[str, Field(description="Search text. Supports quoted phrases for exact match.")],
limit: Annotated[int, Field(description="Max results to return, 1-50. Default 10.", ge=1, le=50)] = 10,
) -> str:
...Version A works. It'll pass whatever the model sends, and FastMCP will happily coerce a string and an int. But the model deciding how to call it has almost nothing to go on beyond the parameter names. Version B turns the schema into documentation — constraints, format expectations, and behavioral hints all travel with the tool definition itself, which matters most exactly when the model is choosing between several plausible ways to fill in a parameter.
For anything with more than two or three related parameters, or a genuinely compound shape (a list of objects, a nested structure), reach for a full Pydantic model instead of stacking loose parameters:
class ReferenceImage(BaseModel):
url: str = Field(description="Public URL of the reference image.")
weight: float = Field(default=1.0, description="Influence weight, 0.0-1.0.")
def generate_image(
prompt: Annotated[str, Field(description="Detailed description of the desired image.")],
references: Annotated[Optional[List[ReferenceImage]], Field(description="Optional style/subject references.")] = None,
) -> list:
...This isn't just tidiness. A schema built this way gives the model a real, nested JSON structure to reason about instead of a flat bag of loosely related strings — and it's the difference between a tool the model uses correctly most of the time, and one that generates plausible-looking but subtly wrong calls that fail validation and burn a retry.
The practical rule: every parameter should have a description that answers "what would a careful person need to know to fill this in correctly?" If you can't write that sentence, that's often a sign the parameter itself needs a better name or a narrower type — not just better prose.
One validation detail worth knowing: input validation happens before your function body ever runs. If the model sends something that doesn't match your schema, FastMCP rejects it at the protocol level and returns an error to the client — your function's own try/except never even sees it. That's a feature, not a gotcha, but it means schema quality is doing real defensive work, not just cosmetic work.
The four ways to handle authentication — and when each one is right
Across a large fleet of MCP servers, authentication tends to converge on one of four patterns. None of them is universally "correct" — the right one depends entirely on what you're protecting and who's calling.
Pattern 1: Plain environment variable. The simplest possible case — read an API key from the environment, use it directly.
def _get_client():
api_key = os.getenv("PROVIDER_API_KEY")
if not api_key:
raise ConfigError("PROVIDER_API_KEY is not configured.")
return Client(api_key=api_key)This is the right call when you're calling a single first-party API with one shared credential, and there's no per-user identity to distinguish. It fails fast with a clear error if misconfigured, which matters more than it sounds — a tool that silently proceeds with a missing credential and fails deep inside a third-party SDK call is much harder to debug than one that refuses to start cleanly.
Pattern 2: Env var → static header. One step up — the credential is still a single shared secret, but it needs to be assembled into an Authorization header manually, usually because there's no vendor SDK.
headers = {"Authorization": f"Bearer {os.getenv('SERVICE_TOKEN')}"}Fine for internal or low-sensitivity services. Worth pairing with typed exception handling for the HTTP call itself since you lose the nicer error surface an SDK would normally give you.
Pattern 3: Full OAuth2 Authorization Code + PKCE. This is the heaviest pattern, and appropriate when the MCP server itself needs to act as a protected resource — gating access per-caller rather than trusting anything that can reach it. I've seen this built as a hand-rolled Starlette middleware layer: a BearerAuthMiddleware checking incoming tokens against a valid set, with /authorize and /token routes implementing the PKCE exchange.
If you're building this for real, not as a demo, the two things worth getting right that a demo implementation often skips: token persistence (not just an in-memory set that resets on restart) and expiry/revocation. A demo that stores valid tokens in a Python dict is a fine way to learn the flow, it is not a production auth layer.
Pattern 4: Multi-tier precedence with managed refresh. The most sophisticated pattern I've seen in practice: a tool checks, in order, an explicit Authorization header if the caller supplied one, failing fast if it's present but invalid, then an explicit token passed as a tool parameter, then a "managed mode" fallback that reads a stored refresh token, calls the vendor's refresh endpoint, and persists the new access token back to config, all before making the actual API call.
This pattern earns its complexity when you're integrating a business SaaS product where tokens expire on a schedule and you want the tool to keep working across a long-lived deployment without manual re-auth. The key implementation detail: mask tokens in logs. Log only the last four characters if you must log a token at all, f"...{token[-4:]}", never the full value. This sounds obvious until you've seen a full bearer token sitting in plaintext in an application log because someone wanted a quick debug line.
Match the pattern to what's protected, not to whatever was fastest to wire up.
Choosing between them: match the pattern to what's actually being protected. A single-tenant tool calling one API on your own behalf rarely needs more than pattern 1 or 2. A multi-tenant tool, or one gating access to sensitive internal data, deserves pattern 3 or 4, and deserves it built properly, not as a shortcut demo left running in production.
Transport: STDIO vs SSE vs Streamable HTTP, and the auth gap nobody talks about
Every MCP server picks one of three transports, and the choice has real security implications that are easy to overlook because the protocol-level code looks almost identical either way.
STDIO is the default, and it's what you get from a bare mcp.run(). The MCP host, Claude Desktop, or an internal gateway, spawns your server as a subprocess and talks to it over that process's own stdin and stdout. No network socket is ever opened. This is why STDIO needs no authentication story of its own: the client is the process's parent. Its blast radius is whatever the local user's own process already has access to, and there's no separate network attack surface to secure. The tradeoff is that it only ever serves the one client that spawned it.
SSE is what you reach for the moment a tool needs to be reachable over a network by more than one caller, a centrally-hosted tool serving a shared gateway, rather than something spawned fresh per user. This is where I want to flag something I've seen repeatedly: reaching for SSE means giving up STDIO's implicit trust boundary, and it's alarmingly easy to do that without replacing it with anything. A server that switches from mcp.run() to mcp.run(transport="sse") gains network reachability and loses the "only my own spawning process can talk to me" guarantee, and if nothing fills that gap, the tool is now reachable by anyone who can route to its port.
Streamable HTTP is the newer, generally-recommended transport for networked MCP servers, a single HTTP endpoint handling both request and streamed response. In practice, across a large number of servers I've reviewed, I've seen it show up constantly as a documented option, an argparse --transport choice, an env-var-driven code path, and far less often as the transport a server actually, unconditionally runs on. It's worth checking your own fleet honestly here: does "we support Streamable HTTP" mean it's live, or does it mean it's one of several choices=[...] that happens to default to something else at deploy time?
The practical rule: if you're choosing SSE or Streamable HTTP over STDIO, treat that decision as inseparable from an authentication decision. Don't let "we needed it to be reachable over the network" quietly become "we needed it to be reachable by anyone." Pair the transport choice with one of the real auth patterns above, at minimum a validated bearer token, ideally something closer to the OAuth2+PKCE pattern if multiple distinct callers need to be told apart.
Error handling: the difference between "it works" and "it's safe"
This is, in my experience, the single most common quality gap across MCP tools built quickly. It's also one of the easiest to fix once you see the pattern.
Here's the anti-pattern, and I want to be direct about how common it is, it's the dominant error-handling shape I've seen across dozens of independently-built tools:
try:
result = call_external_api()
return format_response(success=True, data=result)
except Exception as e:
return format_response(success=False, error=str(e))This "works." It doesn't crash the server, it returns something structured, and it'll pass a casual code review. The problem is str(e), whatever the underlying exception says, verbatim, goes straight back to whoever's calling your tool. Depending on what threw the exception, that can include internal hostnames, stack traces, database connection strings with credentials still attached, SQL fragments, file paths, or vendor SDK internals that were never meant to be user-facing.
Here's the pattern I'd hold up as the actual standard to build toward, a real internal/external boundary using a small custom exception hierarchy:
class ToolConfigError(Exception):
"""Raised for misconfiguration. Safe to show to callers."""
class ToolGenerationError(Exception):
"""Raised when the external call fails in an expected way. Safe to show to callers."""
def _error(code: str, message: str) -> dict:
return {"success": False, "error_code": code, "message": message}
@mcp.tool()
def do_thing(param: str) -> dict:
try:
...
except ToolConfigError as e:
return _error("config_error", str(e))
except ToolGenerationError as e:
return _error("generation_error", str(e))
except Exception:
logger.exception("Unexpected error in do_thing")
return _error("internal_error", "Something went wrong processing this request.")The distinction that matters: exceptions you raised yourself, with a message you wrote specifically to be shown to a caller, are safe to return as-is. Anything you didn't author the message for, a raw SDK exception, a database driver error, an HTTP library's internal exception string, gets logged in full detail on your side, and the caller gets a short, safe, generic message instead.
This is not just a security nicety. Think about what happens when a SQL execution tool's OperationalError includes the actual query and connection details in its exception string, and that string gets returned directly to an LLM that might then surface it, verbatim, in a chat response to an end user. The fix costs maybe ten extra lines per tool. The failure mode it prevents is the kind that ends up in a security review with your name on the commit.
A middle-ground worth knowing about: some tools do partial sanitization, categorizing the error type correctly (ConfigError, ValidationError, OperationalError) but still leaking the raw underlying error text for the cases they didn't explicitly handle. That's better than nothing, but it's not the same as a real boundary. If even one exception branch does str(e) unfiltered, that's the branch an attacker, or just an unlucky user, will eventually hit.
Two real bugs that teach more than any tutorial
Some of the most useful lessons I've picked up didn't come from documentation, they came from finding actual bugs in running code and figuring out exactly why they happened. Here are two, sanitized, because they generalize to mistakes almost anyone can make.
Bug one: a transport flag that did nothing. A server accepted a --sse command-line flag, and its startup code looked like this:
transport = "sse" if "--sse" in sys.argv else "stdio"
logger.info(f"Starting with transport: {transport}")
mcp.run() # <-- transport variable never passed inThe flag is parsed. It's logged. It looks, at a glance, like it's doing something. But mcp.run() is called with no arguments at all, the computed transport value is simply never used. The server always ran STDIO, regardless of the flag, and the log line confidently reported otherwise. Nobody had actually exercised the --sse path in a way that would have caught this, because the log message looked correct.
The lesson generalizes well beyond this one bug: a variable that's computed and logged is not evidence that it's actually being used downstream. If you have any conditional startup logic like this, add a test, even a trivial one, that actually asserts the resulting transport, not just that the code runs without crashing.
Bug two: customizing a throwaway object. A server tried to add CORS middleware to its SSE app before running:
app = mcp.sse_app()
app.add_middleware(CORSMiddleware, allow_origins=["*"])
logger.info("CORS middleware added to SSE app")
mcp.run(transport="sse") # <-- builds a NEW app internally, discarding the one aboveThis looks completely reasonable. It builds an app, customizes it, logs success, and then calls run(), which internally constructs a brand new Starlette app from scratch with no memory of the one that was just customized. The CORS middleware is added to an object that's immediately thrown away. The log line claims success. The actual running server never received the middleware at all.
The deeper lesson: when a framework method builds and returns something (sse_app()), don't assume that object is the same one a later convenience method (run()) will use internally, unless you've verified it, either by reading the source or by testing the actual behavior. In this case, an actual CORS preflight request would have revealed the bug immediately; a log message would not.
Both of these bugs share a root cause worth naming explicitly: code that looks like it configures something isn't the same as code that's been verified to configure something. A log line asserting success is not a test. If a behavior matters, which transport is live, whether middleware is actually applied, write something that checks the real, observable outcome, not just that the code path executed without raising.
SQL tools: the one capability that punishes shortcuts immediately
If you're building a tool that lets a model run SQL against a real database, this is the section to read twice. Nothing else in this guide has a faster or more direct path from "convenient shortcut" to "serious incident."
The pattern worth building toward is defense in depth, with two genuinely independent layers, and independent in the sense that either one alone should be enough to stop the worst outcome, because you should assume the other one might someday have a bug.
Layer one: application-level query validation, before anything touches the database:
BLOCKED_KEYWORDS = {"insert", "update", "delete", "drop", "alter", "truncate", "grant"}
BLOCKED_FUNCTIONS = {"pg_read_file", "pg_sleep", "dblink"}
def validate_query(sql: str) -> None:
normalized = sql.strip().lower()
if not (normalized.startswith("select") or normalized.startswith("with")):
raise ValidationError("Only SELECT and CTE queries are permitted.")
if ";" in sql.rstrip(";"):
raise MultiStatementError("Multi-statement queries are not permitted.")
for keyword in BLOCKED_KEYWORDS:
if re.search(rf"\b{keyword}\b", normalized):
raise ValidationError(f"Query contains a blocked keyword: {keyword}")
for fn in BLOCKED_FUNCTIONS:
if fn in normalized:
raise ValidationError(f"Query uses a blocked function: {fn}")This is an allowlist for the query shape (SELECT/CTE only) combined with a denylist for specific dangerous keywords and functions, plus explicit multi-statement detection, blocking the classic SELECT 1; DROP TABLE users; injection shape.
Layer two: database-level enforcement, a genuinely read-only database role, with default_transaction_read_only=on set at the connection or role level. This is the layer that saves you if there's ever a bug in layer one, a new SQL dialect feature your keyword list doesn't know about, or a creative bypass nobody thought of. One implementation I reviewed documented this explicitly, almost as a warning to future maintainers: both layers are required, neither is optional. I think that's exactly the right framing. Application-level validation is necessarily a blocklist chasing a moving target; the database role is what fails closed when the blocklist inevitably misses something.
Neither layer is optional: layer 1 is a blocklist chasing a moving target, layer 2 fails closed when it misses.
A few more details worth carrying over if you're building this: use connection pooling appropriate to a low-concurrency, short-lived read tool (a NullPool is a reasonable choice, you're not running a high-throughput transactional workload), set explicit statement and connection timeouts so a runaway query can't hang the tool indefinitely, and, tying back to the error-handling section, be careful about how much of the raw database error you return on failure. An OperationalError from a misbehaving query can easily contain fragments of the query itself or internal schema details; categorize the error type for the caller, but don't forward the raw driver exception text.
If your tool exposes SQL execution at all, treat both layers as a hard requirement for shipping, not a nice-to-have to add later. "Later" is a bet that nobody sends a creative query before you get to it.
SSRF: the vulnerability hiding in "just fetch this URL"
Any MCP tool that accepts a user-supplied or model-supplied URL and fetches it is a Server-Side Request Forgery (SSRF) vector by default, and it's an easy thing to miss because the tool itself feels completely benign, "let the model pass in an image URL to process" doesn't sound like a security-sensitive feature until you think through what "fetch this URL" actually means when the URL isn't validated.
The attack shape: instead of a public image URL, the caller supplies something like http://169.254.169.254/latest/meta-data/ (a cloud metadata endpoint) or http://localhost:6379/ (an internal service your tool's host can reach but the outside world can't). Your server, sitting inside your infrastructure with its own network access, fetches it on the caller's behalf, effectively turning your MCP tool into a proxy for probing your own internal network.
The fix is a real hostname-resolution check before any fetch happens, not just a URL-format check:
import socket
import ipaddress
from urllib.parse import urlparse
def is_public_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
try:
resolved_ip = socket.gethostbyname(parsed.hostname)
ip = ipaddress.ip_address(resolved_ip)
except (socket.gaierror, ValueError):
return False
return not (
ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
)
async def fetch_reference_image(url: str) -> bytes:
if not is_public_url(url):
raise ImageValidationError("URL does not resolve to a public address.")
async with httpx.AsyncClient(follow_redirects=False, timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.contentTwo details here matter as much as the IP check itself. First, resolve the hostname yourself and check the resolved IP, checking the URL string alone (rejecting anything containing "localhost", say) is trivially bypassed by DNS rebinding or by using a numeric IP directly. Second, disable redirects (follow_redirects=False). Without this, an attacker can supply a public-looking URL that redirects to an internal address, and your validation check on the original URL never sees the real destination.
One more layer worth adding if your tool processes the fetched content as an image or document: don't trust a declared Content-Type header or file extension. Sniff the actual file's magic bytes to confirm it's what it claims to be before processing it, a URL that returns something claiming to be a PNG but is actually something else entirely is a separate, real risk from SSRF, but it's cheap to guard against once you're already fetching and validating the response.
If you take one thing from this section: any tool parameter that becomes a URL your server fetches is a security boundary, not a convenience feature. Treat it with the same seriousness you'd give a public-facing web application accepting user input, because functionally, that's exactly what it is.
Testing MCP tools without mocking the whole transport layer
Testing is, honestly, the area where I've seen the widest gap between "declared" and "actually happening" across MCP server codebases. A pytest dependency shows up in a lot of pyproject.toml files as a dev dependency; genuine automated tests exercising real logic are much rarer. Understanding why makes it easier to fix.
The instinct, when you first want to test an MCP tool, is to try to test it through the protocol, spin up the server, send it a JSON-RPC tool-call message, inspect the response. This is possible, but it's heavy: you're now testing the transport layer, the schema validation, and your business logic all at once, and a failure could be coming from any of the three.
The pattern that actually works well: separate your business logic from your tool-decorated function entirely.
# retail_client.py — plain Python, no MCP imports at all
class RetailClient:
def __init__(self, base_url: str, token: str):
self.base_url = base_url
self.token = token
def search_by_pincode(self, pincode: str) -> dict:
response = httpx.get(
f"{self.base_url}/search",
params={"pincode": pincode},
headers={"Authorization": f"Bearer {self.token}"},
timeout=10.0,
)
response.raise_for_status()
return response.json()
# server.py — thin MCP wrapper
from retail_client import RetailClient
client = RetailClient(base_url=os.getenv("BASE_URL"), token=os.getenv("API_TOKEN"))
@mcp.tool()
def search_outlets_by_pincode(pincode: str) -> dict:
"""Find retail outlets near a given pincode."""
return client.search_by_pincode(pincode)Now your tests import RetailClient directly, no MCP server, no transport, no protocol messages involved at all:
class TestSearchByPincode:
def test_returns_results_for_valid_pincode(self):
client = RetailClient(base_url="https://fake.test", token="test-token")
# env-var toggle or a real mocking library substitutes the real HTTP call here
result = client.search_by_pincode("400001")
assert "outlets" in resultThis is a genuinely underused technique, one worth calling out because I've seen it done well in exactly the servers that also tend to be the best-engineered overall, which isn't a coincidence. A tool that's hard to test is usually a tool where business logic and protocol-handling got tangled together; the act of separating them for testability tends to also produce a cleaner design.
A practical note on mocking: you don't strictly need a mocking library to get value here, an environment-variable toggle that switches between a real HTTP call and a canned stub response is a legitimate, lightweight way to test without hitting a live external service. That said, if your test suite grows past a handful of cases, a real mocking tool (unittest.mock, pytest-httpx, or similar) will save you from writing increasingly elaborate stub logic by hand. Either approach beats what I've seen most often: files named test_*.py that are actually manual smoke scripts, no assertions, just a script that hits a live server and prints PASS/FAIL for a human to read. That naming convention is misleading in a way that matters: a CI system, or a teammate, skimming for "does this project have tests" will see the file and assume real coverage exists when it doesn't.
The minimum bar I'd set for any new tool: if it has business logic beyond a one-line pass-through to an external call, that logic lives in its own module, and that module has at least one real, assertion-based test that runs without hitting a live external service.
The case for a shared internal library (and why most teams skip it)
If you're building more than a handful of MCP servers, especially across a team, over time, you will end up solving the same small set of problems repeatedly: reading and validating an auth token, making an SSRF-safe HTTP fetch, shaping a structured error response, rendering a piece of reusable UI. Every server that doesn't share this logic ends up vendoring its own copy, and those copies drift.
I've seen this play out concretely: three separate implementations of the same third-party spreadsheet integration across three different servers, close enough in their opening structure that they were clearly built by copying and adapting rather than by independent design. Two servers built around the same fuel-retail-style API, both well over a thousand lines, structurally near-identical forks of each other. A UI-rendering helper module, blocks, charts, a renderer, a theme system, copy-pasted verbatim between two otherwise-unrelated servers.
None of this is a bug in the sense of "broken code." Each individual server works. But the cost shows up later: a bug fixed in one copy doesn't propagate to the other two. A security improvement, say, adding the SSRF check above, has to be manually ported into every server that fetches URLs, and it's easy to remember for the next new server and forget for the fifteen existing ones.
Why teams skip this anyway, and why it's not simply negligence: independent, self-contained servers are genuinely easier to deploy, version, and reason about individually. A shared internal package adds real coordination cost, someone has to own it, version it, and every server that depends on it now has a dependency that can break independently of its own release cycle. For a small number of servers, vendoring your own copy of a fifty-line SSRF check is completely reasonable; the overhead of a shared package would exceed the problem it solves.
The calculus changes once you're past a handful of servers and start seeing genuine duplication of meaningfully sized logic, the auth-token refresh dance, a full exception hierarchy, a UI-rendering framework. At that point, a small shared package covering just the highest-value, most-duplicated pieces (structured error envelopes, the SSRF-safe fetch helper, common Pydantic base models) tends to pay for itself quickly, especially for security-relevant code where you want exactly one place to fix a vulnerability, not fifteen.
A practical middle ground if a full shared package feels premature: keep a single, well-documented reference implementation, one server that does auth, error handling, and validation about as well as you currently know how, and treat it explicitly as the template for the next new server, rather than whichever existing server happens to be open in someone's editor when they start copying.
A practical checklist for your next MCP tool
Pulling everything above into something you can actually run against a PR:
Schema and design
- Every parameter has a
Field(description=...)that explains constraints and intent, not just a bare type hint - Compound or nested input uses a Pydantic model, not a flat pile of loosely related parameters
- The tool's own docstring/description is written for the calling model's decision-making, not just as human documentation
Auth
- The auth pattern matches the actual sensitivity of what's being protected, not just "whatever was fastest to wire up"
- Tokens are never logged in full, mask to the last few characters if you log them at all
- If the tool is reachable over SSE or Streamable HTTP, there is an explicit, tested auth layer, not just "it's on our internal network"
Error handling
- There's a real internal/external boundary, exceptions you authored are safe to return; anything else gets logged in full and replaced with a generic message
- No exception branch does a bare
str(e)back to the caller - Config errors fail fast and loud at startup, not silently deep inside a function call
Input safety
- Any tool that fetches a user- or model-supplied URL validates the resolved IP, not just the URL string, and disables redirects
- Any tool accepting a file path checks it against an allowlisted base directory before touching the filesystem
- Any tool executing SQL has both an application-level query validator and a genuinely read-only database role, not just one or the other
Testing
- Business logic lives in a plain Python module separate from the
@mcp.tool()-decorated function - At least one real, assertion-based test exercises that module without hitting a live external service
- Files named
test_*.pyactually contain automated assertions, not a manual smoke script that prints PASS/FAIL for a human
Housekeeping
.envis in.gitignore, and, check this directly, don't assume, a real secret has never been committed to the file's history, not just the current working tree- You've deliberately picked one MCP SDK and one logging library rather than defaulting to whatever the last copy-pasted template used
- If startup logic branches on a flag or env var (transport choice, feature toggle), there's a test that verifies the actual resulting behavior, not just that the branch was logged
None of this is exotic. Almost every item here costs minutes to add when a tool is first written, and hours, or a genuine incident, to retrofit after the fact once the pattern's been copied into another dozen servers. The single highest-leverage habit, if I had to pick one: build the second server by copying your best existing one, not your most recent one.
This guide reflects patterns observed across a real fleet of production MCP servers, the good ones and the ones that needed fixing. If you're hitting something specific in your own MCP work, a transport auth gap, a schema design question, a tricky SDK migration, we'd like to hear about it.