The protocol is the easy part of building an MCP server. With the official SDKs, a working server is under 100 lines and an afternoon; whether an agent can actually use it comes down to three things the spec doesn't cover: tool design, output-size discipline, and testing against more than one client. After shipping three servers (an internal catalogue, a ticketing bridge, and a metrics query tool), those three account for essentially every rework I've had to do.

Tools are prompts, not endpoints

The instinct is to mirror your REST API: one tool per endpoint, CRUD everywhere. Resist it. The model reads your tool list and descriptions as part of its prompt, and fifteen overlapping CRUD tools produce worse call accuracy than five task-shaped ones. Design one tool per user intention, and write the description like documentation for a capable but literal colleague: what it does, when to use it, when not to.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("catalogue")

@mcp.tool()
def search_apps(query: str, category: str | None = None, limit: int = 10) -> str:
    """Search the self-hosted app catalogue by name, feature, or tag.

    Use this before recommending any app, even if you think you know the
    answer. Returns up to `limit` results as JSON: slug, name, category,
    licence, and a one-line summary. For full details on one app, call
    get_app with its slug instead of raising the limit.
    """
    ...

The "when not to" sentence matters more than it looks. Without the pointer to get_app, models raise limit to 50 and drown themselves. Everything in tool calling patterns about schema design applies verbatim here — MCP is a packaging format for those patterns, not a replacement for them.

Two hard-won specifics: keep the tool count under about 15 (clients inject every definition into context, and accuracy degrades as the list grows), and never rename a tool casually — client-side allowlists and user muscle memory both break.

Output discipline: the agent pays per token

Your tool result lands in a context window with a budget. A result over roughly 2,000 tokens starts crowding out the conversation; several of those and the agent forgets its own plan. The rules I now apply by default:

  • Return compact JSON, not prose. No pretty-printing, no repeated keys you can hoist.
  • Default limit to 10, cap it at 50 server-side regardless of what's asked.
  • Paginate with an opaque cursor rather than offset, and say so in the description ("pass cursor from the previous result to continue").
  • Truncate any field over ~500 characters with an explicit marker and a tool that fetches the full value. A silent truncation makes the model reason over data it doesn't know is incomplete.

The test: paste your worst-case tool output into a token counter. If a single result exceeds ~10% of a 32k context, you've built a data export, not a tool.

stdio first, Streamable HTTP when you need remote

Start with stdio. The client spawns your process, auth is inherited from the user's environment, and there's nothing to deploy. Two classic stdio bugs: writing logs to stdout (which corrupts the JSON-RPC framing — log to stderr, always) and buffering output so the client times out on initialise.

Move to Streamable HTTP only when you need a remote or multi-user server. Note that the older HTTP+SSE transport was deprecated in the 2025-03-26 spec revision in favour of Streamable HTTP, and clients vary in what they support — pin the protocol version you test against and say which one in your README. The official spec and SDKs are the canonical reference; the spec is versioned by date, so cite the revision.

Auth is where remote servers get real

For a local stdio server, environment variables are fine. For anything remote, the spec settled on OAuth 2.1 with the server acting as a resource server; the SDKs handle the token dance but you still own scoping. Scope tokens to tools, not to the server — a client that only needs search_apps should not hold a token that can call delete_app. For internal-only deployments, a bearer token checked at a reverse proxy is honest and adequate; just don't pretend it's user-level auth.

Treat every tool result you return as content someone else's agent will trust. If your server serves data users don't fully control (tickets, emails, web content), you're a prompt-injection vector for every client that connects — the threat model is the client's problem to mitigate but yours to not make worse. Sanitise where you can and document where you can't.

Test against three clients, not one

Clients differ in ways the spec permits and you won't predict: how they render results, whether they support resources and prompts at all (several major clients still don't — put anything important in tools), tool-name length limits, and how aggressively they summarise long results. My minimum matrix is MCP Inspector (npx @modelcontextprotocol/inspector) for protocol-level debugging, Claude Desktop or Claude Code as the daily driver, and one other IDE-embedded client. A server that only ever met one client has undiscovered bugs.

For long-running tools, send progress notifications — clients that support them show a live status, and clients that don't just ignore them. Anything over ~10 seconds without progress gets killed by some client somewhere.

Return errors the model can act on

Models retry well when told exactly what was wrong, and flail when given status codes. "date must be YYYY-MM-DD, got '3rd of March'" produces a correct retry on the next call; "400 Bad Request" produces three guesses. Validate arguments eagerly, name the offending field, show the expected format, and include one example. This is the cheapest reliability improvement available — it costs you string formatting.

What I'd do

Start from the official Python or TypeScript SDK, stdio transport, five task-shaped tools with when-to-use descriptions, JSON output capped at 10 results, errors that name the fix. Test in MCP Inspector before any real client. Add Streamable HTTP and OAuth only when a second user needs it, and version your tool names from day one (search_apps can gain fields; renaming it is a breaking change). The servers that survive are boring protocol citizens with excellent prose in their tool descriptions — the model can't see your architecture, only your words.