How to Build a Custom MCP Server for Your Product
MCP tutorial auth-scoping fail-soft errors version-pinningUp front: this walks you through a real, working single-tool server, then the hardening layer most write-ups leave out. If you get to the end and decide you'd rather have someone else do the hardening part, there's a fixed-scope offer for that at the bottom. If you build it yourself and never buy anything, that's a fine outcome too — the code below works either way.
The gap in most MCP tutorials
Search "how to build an MCP server" and you'll get a dozen good, official-adjacent guides — the official MCP docs, vendor walkthroughs from IBM, Microsoft, and others — and they all teach the same shape correctly: a weather lookup, a search tool, two functions decorated and exposed over stdio. That part is genuinely easy now; the SDKs have gotten good.
What almost none of them show is what happens after the demo: your agent calls a tool against your internal system, the upstream API times out or the token is scoped too broadly, and the whole agent session goes down with it. The SDK wrapper was never the hard part. The production layer — auth-scoping, fail-soft behavior, and version-pinning — is the part that decides whether this thing pages you at 2am.
Step 1 — scaffold one tool, not a platform
Resist building a multi-tool platform on day one. Pick the single internal action your
agent actually needs (e.g. "issue a refund," "look up an order," "restart a job") and build
the manifest and one tool around it. Using Python's official SDK (pip install mcp),
the fast path is FastMCP:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("internal-billing")
@mcp.tool()
async def refund(order_id: str, amount_cents: int) -> dict:
"""Issue a refund for an order. Requires billing:write scope."""
# naive version — this is the part everyone stops at
result = await billing_client.refund(order_id, amount_cents)
return {"status": "ok", "refund_id": result.id}
if __name__ == "__main__":
mcp.run()
This runs. It'll pass a demo. It is also exactly the version that breaks the first time
billing_client is unreachable, or the token behind it can do more than issue
refunds.
Step 2 — scope the auth before you scope anything else
The single biggest risk in a hand-rolled MCP server isn't a bug in the tool logic — it's a
credential that can do more than the tool needs. If your billing client's API key can also
delete customers, then a prompt-injected or simply confused agent has that blast radius too,
whether or not the refund function itself is careful. Scope the credential
itself, not just the code path:
- Mint a token/API key scoped to exactly the actions this server exposes (billing:write, not billing:*) — at the identity-provider or API-gateway layer, not in application code.
- If the upstream has no fine-grained scopes, wrap it: build a narrow internal proxy that only accepts the specific calls your tool needs, and point the MCP server at the proxy instead of the raw API.
- Log which scope was used on every call. If you can't answer "what could this tool have done" from your own logs, the scoping isn't finished.
Step 3 — make failure boring, not fatal
A dead dependency should return a clean, structured error — never an unhandled exception that kills the process the agent is talking to. "Fail-soft" here means specifically: catch the failure modes you know about, return something the calling model can reason about, and keep the server alive for the next call.
@mcp.tool()
async def refund(order_id: str, amount_cents: int) -> dict:
"""Issue a refund for an order. Requires billing:write scope."""
try:
result = await billing_client.refund(order_id, amount_cents)
return {"status": "ok", "refund_id": result.id}
except billing_client.RateLimited:
return {"status": "error", "retryable": True,
"message": "Billing API is rate-limited. Retry after a short backoff."}
except billing_client.Unauthorized:
return {"status": "error", "retryable": False,
"message": "Refund scope rejected by billing API — check credential scope."}
except billing_client.UpstreamDown:
return {"status": "error", "retryable": True,
"message": "Billing API unreachable. Do not retry immediately."}
Notice the shape: every branch returns, none of them raise past the tool boundary, and each
error tells the calling agent whether retrying makes sense. That last part matters more than
it looks — a model that gets a bare 500 will often retry blindly; a model that gets
"retryable": false with a reason won't burn three more calls finding that out.
Step 4 — pin your versions on both axes
Two things drift silently if you don't pin them: your own dependencies, and the upstream API's response shape. Lock both:
- Pin dependencies exactly (a
requirements.txtwith==, or a lockfile) — not just for reproducibility, but because an unpinned transitive dependency bump is a common source of "it worked yesterday." - If the upstream API is versioned, send the version header explicitly rather than relying on whatever the default is this month. If it isn't versioned, add a response-shape check (even a lightweight schema assertion) so a silent upstream change fails loud in your logs instead of quietly returning malformed data to the agent.
Step 5 — test the failure paths, not just the happy one
A server that's only ever been tested against a live, healthy upstream hasn't been tested against the conditions that actually cause incidents. At minimum, write tests that force each failure branch above (rate-limited, unauthorized, upstream down) and assert the tool returns the structured error — not that it happens to work when everything's fine. If you want the deeper version of this, see the companion piece on MCP server testing.
The part that doesn't fit in a blog post
Everything above generalizes. What doesn't generalize as cleanly: what to do when the upstream API is undocumented and you're reverse-engineering response shapes from network traffic, how to handle an auth model that doesn't map cleanly to scopes, and what "done" actually means for your specific tool. That's the part that turns a working prototype into something your team can hand off and stop babysitting — and it's genuinely a few days of focused work, not a weekend read.
Want the hardened version built for you?
The MCP Integration Sprint is exactly this — scope, build, harden, handoff — for one internal tool, fixed-scope, in 1–2 weeks. Auth-scoped, fail-soft, version-pinned, tested, documented.
Scope a sprint See pricing