Protocol revision 2026-07-28
Building an MCP Server
What a server owes a client beyond tools: discovery, resources and prompts, cacheable lists, error conventions, and shutdown that does not need a kill signal.
A minimal server answers three methods and fits on one screen. The field note builds exactly that, with no dependencies. This page is about the parts you reach for once the minimal version works.
Discovery is mandatory for you, optional for them
Servers must implement server/discover. Clients are free to skip it and call whatever they like, handling a version error if one comes back.
{"jsonrpc":"2.0","id":"d1","result":{
"resultType":"complete",
"supportedVersions":["2026-07-28"],
"capabilities":{"tools":{},"resources":{}},
"instructions":"Weather and resource utilities.",
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"weather","version":"1.0.0"}},
"ttlMs":3600000,
"cacheScope":"public"}}Two fields deserve attention. instructions is natural-language guidance aimed at the model, which makes it a model-facing string and therefore an injection surface when it comes from someone else. serverInfo is self-reported and unverified: the specification tells clients not to make security decisions from it. It is a label, not an identity.
Three primitives, three audiences
Tools are functions the model decides to call. They are for actions and for lookups whose arguments the model has to choose.
Resources are addressable content the client can read, identified by URI. They are for data whose identity is known without the model guessing: an open file, a selected record, the current document. Modelling "read the config" as a tool forces the model to invent a path; modelling it as a resource lets the host attach it.
Prompts are templates the user invokes deliberately, typically surfacing as slash commands. They are the primitive people forget, and they are the right answer whenever the intent is "the user wants to start this workflow" rather than "the model decided to".
A useful rule: if the model should never call it unprompted, it is not a tool.
Cacheable lists
Since 2026-07-28, list and resource-read results can carry cache hints modelled on HTTP.
ttlMs how long this result stays fresh
cacheScope "public" safe to share across users
"private" this answer depended on who askedThis matters more than it sounds. Without it, a stateless protocol means every client re-fetches your tool list on every connection, and a client that reconnects often turns discovery into your busiest endpoint. Getting cacheScope wrong is worse than not setting it: marking a per-user result public invites an intermediary to serve one user's answer to another.
Error conventions
MCP splits failure into two channels, and the split is deliberate.
tool threw, model can recover result { content: [...], isError: true }
method does not exist error { code: -32601 }
version not supported error { code: -32022, data.supported: [...] }
header disagrees with body error { code: -32020 } (HTTP only)A tool that failed in a way the model could plausibly work around comes back as a normal result with isError set, so the text lands in the context window and the model can try something else. A protocol failure is a JSON-RPC error, which the model never sees.
The trap is what you put in that content. A stack trace with absolute paths, an SQL statement, an internal hostname: all of it is now in a context window, possibly one belonging to a model you do not run. Error text in a tool result is published output, not a log line.
Statelessness is not optional any more
There is no session to hang state on. Each request stands alone, and the client may be talking to a different process than it was a moment ago.
Anything that has to survive between calls belongs in your own storage, keyed by something in the request, or it belongs in the request itself. This is most visible in multi round-trip calls: a tool that asks the user for a missing argument gets called a second time from scratch, and has to reconstruct everything it knew.
For a stdio server it is tempting to ignore this, because your process does have a lifetime. Resist it. The specification is explicit that a client should restart a crashed server and simply retry, which is only safe if you never leaned on the process living.