Building an MCP Server
The input schema tells the model what to send as well as validating what arrives. Returning tools in a stable order protects callers' prompt caches, and an error that names the rule lets the model correct itself.
The schema is the contract the model reads
A tool definition carries a name, a description, and an inputSchema that
"MUST be a valid JSON Schema object (not null)". An optional
outputSchema describes what comes back, and if you provide one, "Servers
MUST provide structured results that conform to this schema."
The schema does two jobs at once, and the second is easy to forget. It validates what arrives, and it tells the model what to send. A parameter with no description is a parameter the model fills in by guessing.
{
"name": "create_ticket",
"description": "Open a support ticket for a customer.",
"inputSchema": {
"type": "object",
"properties": {
"customer_id": { "type": "string", "description": "Internal customer ID, format CUS-00000" },
"priority": { "type": "string", "enum": ["low", "normal", "urgent"] },
"summary": { "type": "string", "description": "One line, under 80 characters" },
},
"required": ["customer_id", "summary"],
"additionalProperties": false,
},
}
Two habits do most of the work. Use an enum wherever the set of acceptable
values is knowable, because a closed set is something the model cannot invent
around. And describe the format of anything with a shape, because CUS-00000
tells the model something that "type": "string" does not.
For a tool with no parameters, the specification recommends { "type": "object", "additionalProperties": false }, which "explicitly accepts only empty objects",
over the looser { "type": "object" }.
Naming a tool, and the collision nobody plans for
Names have rules that are easy to violate by accident. They should be 1 to 128 characters, are case-sensitive, and should use only ASCII letters, digits, underscore, hyphen and dot. No spaces, no commas.
Uniqueness has a catch. Names are unique within a server and nowhere else. The
specification spells out the consequence: clients that aggregate several servers
"MAY encounter naming collisions (for example, two servers each exposing a
search tool) and SHOULD implement a disambiguation strategy such as
prefixing tool names with a server identifier."
If your server exposes a tool called search, a host running it alongside three
others has four. Naming it for your domain rather than for the verb is a small
courtesy that costs nothing.
One more detail with a direct cost attached: servers "SHOULD return tools in a deterministic order", because stable ordering "improves LLM prompt cache hit rates when tools are included in model context." A server that iterates a hash map and returns its tools in a different order each time makes every caller's cached prefix miss. Sort the list.
What a result should contain, and what it costs
A result is content, an array that can hold text, images, audio, resource
links or embedded resources, and optionally structuredContent, a JSON value
conforming to your outputSchema. When you return structured content, the
specification says a tool "SHOULD also return the serialized JSON in a
TextContent block" for compatibility.
The design question is what to put in it, and the constraint is that everything you return enters the model's context and stays there. It is billed on this turn and on every subsequent turn of the conversation.
This is why a raw API payload is the wrong answer. Forty kilobytes of JSON with forty fields the model does not need is forty kilobytes of context spent, an attention budget consumed, and a higher chance the relevant value gets lost among the irrelevant ones. Context engineering makes the general case; a tool result is where it bites hardest, because the cost recurs.
Return the fields that answer the question. If the caller might need the rest,
return a resource_link pointing at it rather than the content itself, and let
the host fetch it when something needs it.
Errors the model can act on, and errors it cannot
The specification splits errors in two, and the split is about recovery.
Protocol errors are JSON-RPC errors: unknown tool, malformed request, server failure. These "indicate issues with the request structure itself that models are less likely to be able to fix."
Tool execution errors come back as an ordinary result with isError: true,
and they "contain actionable feedback that language models can use to
self-correct and retry with adjusted parameters." Clients "SHOULD provide
tool execution errors to language models to enable self-correction."
The specification's own example is the shape to copy:
{
"content": [
{
"type": "text",
"text": "Invalid departure date: must be in the future. Current date is 08/08/2025.",
},
],
"isError": true,
}
That message does something a status code cannot. It states the rule, and it
supplies the fact the model needs to satisfy it. A model that sent a past date
can now send a valid one. Compare it with 400 Bad Request, which tells the
model that something was wrong and nothing about what to do next.
So write error text for a reader who is going to try again, and put the correcting information in it.
Carrying state between calls
The protocol has no session, so a server cannot rely on one call knowing about
the last. The specification's non-normative guidance is to return an explicit
handle from a creating tool and accept it as an argument afterwards, as a
shopping cart returns a basket_id that later calls pass back.
Four cautions come with it, and they are worth following because the model, not your code, is what carries the handle forward.
Validate authorization against the handle on every call, because "a handle is a name, not a capability." Make handles opaque, since ones "that encode internal structure invite parsing or guessing." State the lifetime in the creating tool's description, so the model can see it when deciding to create state. And when a handle expires, return a tool execution error saying so, so the model can create a new one instead of failing.
Further reading
- MCP, Tools, revision 2026-07-28: schema rules, naming, result shapes, the error split, and stateful tools.
- MCP server: which primitive a capability should be, and the capability boundary.
- MCP in one sitting: the roles and a minimal server end to end.
- Connect to a local server: running the server you have written.
Knowledge check
Question 1 of 4
Sign in to save your progress and pick up where you left off.