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:

  • readOnlyHint defaults to false
  • destructiveHint defaults to true
  • idempotentHint defaults 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 outputSchema when the shape is fixed, and mirror it as text.
  • isError: true for a tool that ran and failed, JSON-RPC errors for calls that did not.
  • Set readOnlyHint explicitly: the default assumes your tool is destructive.
  • Annotations are hints. Authorisation lives on the server.

Related

Next step

Working on something this touches?

If this post lines up with a problem you have, I would rather hear about the problem than the post. Tell me what you are building.

  • Replies: Within two working days
  • Based in: the United States
  • Open to: full time, contract, remote or hybrid

Pages

Projects

Writing

Actions