Writing an MCP server is easy; the SDKs have made a hello-world server a twenty-minute job since 2025. Writing a server that a model uses correctly is the actual work, and it hinges on a handful of design decisions that the spec deliberately leaves to you. This is a field guide to those decisions.

It assumes you know the protocol basics — client, server, and the three primitives — covered in what MCP actually is. It also helps to remember from MCP vs function calling that everything you write in a tool definition is injected into the model's context: server design is prompt engineering with a schema attached. The other cluster articles cover security and the servers worth studying.

Tool or resource? The one question that shapes everything

The first decision for every capability you expose: is this a tool or a resource? The rule that survives contact with practice:

Tools are for things the model triggers. Resources are passive data the model reads.

If invoking it changes something — creates an issue, sends a message, writes a file, kicks off a job — it is unambiguously a tool. If it is a known piece of data that provides context — a file, a schema, a document, a log — it is a resource with a URI, and the client decides when to attach it to the conversation.

The gray zone is reads that need parameters. A resource URI is fixed or template-shaped; it cannot absorb an arbitrary query the model composes at runtime. So "fetch the user with ID 42" can be a resource (db://users/42), but "find users who signed up last week and never logged in" has to be a read-only tool taking a query argument. A useful pattern from mature database servers: expose the stable, enumerable data (schemas, table lists) as resources, and provide one well-guarded query tool for everything open-ended.

Why care? Because clients treat the two differently. Tool calls are model-initiated and, in well-behaved clients, gated behind user confirmation for anything that writes. Resources are often user-attached and skip that ceremony. Model something as a tool when it should be a resource and you generate consent noise; model it as a resource when it changes state and you have hidden a side effect where no one looks for one.

Naming: verbs for tools, nouns for resources

Models pick tools by reading names and descriptions, so naming is a correctness issue, not a style issue.

Tools: short, verb-led, snake_case. create_issue, list_repos, search_messages, run_query. The verb tells the model what happens; the noun tells it to what. Avoid clever abbreviations (mk_iss), avoid vague catch-alls (do_github_action with a mode parameter — the model will guess the mode wrong), and keep one action per tool. If you find yourself designing an action enum argument, that is usually several tools wearing a coat.

Resources: noun-led URIs. file:///project/README.md, db://users/42, sentry://issues/recent. The URI scheme names the domain, the path names the thing, and nothing in it implies an action, because reading a resource performs none.

Consistency across the server matters as much as any individual name. If listing repositories is list_repos, then listing issues should be list_issues, not get_all_issues. Models generalize from patterns; give them a clean pattern to generalize from.

Descriptions: one clear sentence, written for the model

The description is the highest-leverage text in your server. The model decides whether and how to call your tool based on the description alone — it cannot read your code, your README, or your intentions.

The bar: one clear sentence saying what the tool does, plus whatever constraint prevents the most likely misuse. "Create a new issue in the specified repository. Requires the repo to exist; does not create repositories." For arguments, put format constraints in the schema where possible and state the unavoidable ones plainly: "ISO 8601 date, e.g. 2026-07-13."

Two failure modes to avoid. Empty descriptions ("Creates an issue" on a tool named create_issue adds nothing — say what makes it non-obvious instead). And essays: a 300-word description burns context tokens on every single request and buries the sentence the model needed. If a tool genuinely requires paragraphs of explanation, the tool's interface is too complicated — simplify the tool, not the prose.

Remember also that descriptions are an injection surface — a client will feed whatever you write straight into the model's context. Keep instructions out of descriptions ("always call this tool first" is a smell), both because it is bad manners and because clients are increasingly scanning for exactly that pattern, as covered in security considerations.

Errors: return them as results, not protocol errors

This one is counterintuitive and matters enormously in practice. When a tool call fails — bad arguments, missing record, upstream API down — return the failure as the tool result, flagged as an error — several SDKs expose an isError-style flag on the result for this, an SDK convention rather than a field the core spec standardizes, which keeps JSON-RPC error semantics for protocol-level failures — with a message written for the model to read.

The reasoning is about who sees what. A JSON-RPC protocol error terminates at the client application; the model typically sees a generic "tool failed" or nothing at all, and the conversation dead-ends. An error inside the tool result flows into the model's context like any other output, so the model can read "repository 'nerdsip/websit' not found — did you mean 'nerdsip/website'?" and retry correctly on its own. Well-written error results turn failures into self-healing loops; protocol errors turn them into support tickets.

Reserve actual protocol errors for actual protocol problems: unknown tool name, unparseable request, transport failure. Everything that happens inside a valid tool call belongs in the result.

Write error messages the way you would write them for a sharp junior colleague: what failed, why, and what a valid retry looks like. "Invalid input" is useless to a model for the same reason it is useless to a human.

Version your server, log every call

Two operational habits separate maintained servers from abandoned ones.

Version explicitly. Declare a version in your server metadata and bump it when tool signatures change. Renaming a tool or changing an argument type silently breaks every prompt and workflow users built against the old shape — and users of MCP servers often cannot see your release notes, only your tool list. Treat tool signatures as public API, because that is exactly what they are.

Log every tool call — name, arguments, result or error, duration, timestamp. When a user reports "the model keeps doing something weird with your server," the log is the only way to distinguish the three usual suspects: your bug, the model misreading a description, or a genuinely malformed call. Log to stderr or a file for stdio servers (never stdout — that is the protocol channel), and to your normal observability stack for remote ones. As of mid-2026, client-side logging of tool calls is still inconsistent, so if you do not log it, odds are nobody did.

The takeaway

The spec gives you tools, resources, and prompts; everything that makes a server pleasant is convention on top. Model triggered actions as tools and passive data as resources. Name with verb-led tool names and noun-led URIs, consistently. Spend real effort on one-sentence descriptions, because they are the interface the model actually reads. Put failures in error-flagged results so models can recover, version your tool signatures like the public API they are, and log every call. None of this is hard. All of it is the difference between a server people install and one they uninstall the same afternoon.