An MCP tool is an API whose caller reads the documentation at runtime and then decides, on its own, whether your tool is the right one. That changes what “good interface” means. With a human integrator you can be terse and let them read the code. Here the description is the interface, and a model that picks the wrong tool produces a confident wrong answer rather than a stack trace.
What the caller actually sees
A tool definition in the protocol carries a name, an optional title for
display, a description, an inputSchema in JSON Schema, and optionally an
outputSchema and annotations. That is the whole surface. Everything the
model knows about your system comes through it.
Tool discovery happens over tools/list, which is paginated with a cursor, and
invocation over tools/call with a name and an arguments object.
1. Write the description for selection, not documentation
The hard problem is not explaining what a tool does. It is helping the model choose between two tools that both sound plausible.
So say when not to use it:
# Weak: true, and useless for choosing.
"Search leads."
# Better: says what it is for, and what it is not for.
"Search leads by company name, owner or status. Returns at most 50 matches
with a cursor for the next page. Use get_lead when you already have a lead
id, and create_lead when no match is found."
The second version answers the question the model is actually asking. Naming the sibling tools inside the description is the single highest-yield thing I have done to cut wrong-tool calls.
2. Constrain in the schema, not in the prose
Anything expressible in JSON Schema belongs in JSON Schema. Prose is advisory; the schema is checkable, and a good client validates against it before the call leaves.
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["new", "qualified", "won", "lost"],
"description": "Lead status to filter by.",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 25},
},
"required": ["status"],
"additionalProperties": False,
}
An enum ends the guessing about whether the value is won, Won or
closed_won. Put a description on each property, not only on the tool: that
is where the model looks when it is filling in one argument. This is the same
argument as validating input at the boundary,
with the twist that here the schema doubles as the prompt.
3. Return structured content when the shape is fixed
If you declare an outputSchema, the server must return structured results
that conform to it, and clients should validate them. That turns a tool result
from prose the model has to parse into data it can rely on.
One compatibility note from the spec: a tool returning structured content
should also return the serialised JSON in a text block, so clients that do not
read structuredContent still get something usable.
4. Two error channels, and they are not interchangeable
This is the part most implementations get wrong.
A protocol error is a JSON-RPC error: unknown tool, malformed arguments, server fault. The call never really happened.
A tool execution error is a normal result with isError: true and a message
in the content. The tool ran and failed: the record was locked, the upstream
API refused, the business rule said no.
The distinction matters because the model sees the second one and can act on
it. Returning a protocol error for “no lead with that id” hides a recoverable
condition behind a transport fault. Returning isError: true with “No lead
matches id 4182. Use search_leads to find one.” gives the model its next move.
5. Annotations are hints, and the defaults are pessimistic
Tool annotations describe behaviour, and the defaults are worth memorising because they are not what people assume:
readOnlyHintdefaults to falsedestructiveHintdefaults to trueidempotentHintdefaults to false
So a tool that only reads, and says nothing, is advertised as a tool that may
destroy things and cannot safely be retried. Clients that gate on these will
treat your harmless search as dangerous. Setting readOnlyHint: true on your
read tools costs one line.
What annotations are not is a security control. The specification is explicit that clients must consider tool annotations untrusted unless they come from a trusted server. They are a hint for presentation and caution, nothing more.
6. The server is the enforcement point
The protocol puts this on the server: validate all tool inputs, implement access controls, rate limit invocations, sanitise outputs.
It is the same rule as enforcing permissions on the API rather than the UI, and it bites harder here. A front end misbehaves when someone goes looking. A model will cheerfully pass whatever your description implied was acceptable, including an id belonging to somebody else’s account, because it has no concept of a tenant boundary unless your server enforces one. Role checks and ownership checks are still two different checks.
The short version
- The description’s job is to help the model choose, so name the alternatives.
- Constrain with enums, bounds and
additionalProperties: false, not prose. - Declare an
outputSchemawhen the shape is fixed, and mirror it as text. isError: truefor a tool that ran and failed, JSON-RPC errors for calls that did not.- Set
readOnlyHintexplicitly: the default assumes your tool is destructive. - Annotations are hints. Authorisation lives on the server.