Skip to content
Chapter 2 of 8 · Tools over MCPThe name, description and schema

14 min read

The name, description and schema

On this page

The description is not documentation about the interface. It is the interface, in the same sense that a function signature is an interface in a statically typed language: it is the only thing the caller consults. The same is true of the schema, which is the only statement about arguments that anything upstream reads — the Python or TypeScript signature behind it is not consulted by the model, the client or the provider.

This is the most reliably surprising fact in this area, and it is surprising because every other habit an engineer has says otherwise. Careful types, a thorough docstring and a defensive implementation are how you communicate a contract to another programmer, and all three are invisible to the reader who actually decides whether to call the tool and with what. There is a second layer to it as well: even the three fields that do cross are not delivered verbatim, because the client between you and the model rewrites names, may drop or fold in the title, and may truncate. What the model saw is a fact you have to capture rather than a fact you can deduce from your own source.

Reading the request the host actually sent

The listing your server returns is not what the model receives; it is the input to a translation the host performs on the third hop. Every host with a debug mode will show you the result of that translation, and it is worth capturing once for a server you own. Here is the tool array from a request a host sent to the Anthropic Messages API, with a delete_branch entry as it arrived:

json
{
  "model": "claude-opus-5",
  "max_tokens": 16000,
  "tools": [
    {
      "name": "mcp__git__delete_branch",
      "description": "Delete a branch.",
      "input_schema": {
        "type": "object",
        "properties": {
          "repo": {"type": "string"},
          "branch": {"type": "string"},
          "force": {"type": "boolean"}
        },
        "required": ["repo", "branch"]
      }
    }
  ],
  "messages": [{"role": "user", "content": "clean up merged dependency branches in platform-api"}]
}

Three things in that block deserve attention. The name has been rewritten — this host namespaces MCP tools as mcp__<server>__<tool> to avoid collisions between servers, so the string the model reasons about is not the string you declared. The description is one sentence, because that is what the server declared as its description; a docstring lives in a Python file that nothing in this pipeline reads. And the schema has no property descriptions, no pattern, no enum on anything, because the server author wrote the schema by hand and the type hints did not generate one.

Compare that against everything the tool's author believed they had communicated:

What the author wroteWhere it livesDoes it cross to the model?
description="Delete a branch."Tool declarationYes — this is the whole briefing
Forty-line docstring on the handlerPython sourceNo
branch: str, force: bool = FalseFunction signatureNo — only the JSON Schema crosses
destructiveHint: trueTool annotationsSent to the client; the host decides whether the model ever sees it
Protected-branch checkHandler bodyNo — the model learns it only by being refused
Seven-day soft-delete windowHandler body and docstringNo
"branch": {"type": "string"}Input schemaYes

The two rows in the middle of that table are where the money is. Everything the author considered the substance of the tool — the safety rails, the recovery window, the semantics of force — is in the two places that never reach the reader who has to decide whether to call it.

One rule sits underneath every row of that table, and extracting it is worth more than memorising the rows, because it also answers the rows the table does not have. What crosses is what is in the listing entry your server returns, and nothing else crosses, for the uninteresting reason that the listing entry is the only thing your server sends. A docstring is not in it. A type annotation is not in it. A conditional in the handler body is not in it. The annotations object is in it, which is why that row's answer is a hedge rather than a flat no: it reaches the client, and what happens to it after that is the client's business. Apply the rule to anything you are unsure about and the answer arrives without the table — is this string a field of the object I return from tools/list?

Moving the substance into the declaration

Because the description crosses and the implementation does not, a rewrite that moves the docstring's content into the declaration changes what the system can do. It is a code change rather than a copy edit.

python
Tool(
    name="delete_branch",
    description=(
        "Delete a branch from a repository. The deletion is soft: the branch is "
        "recoverable for seven days with restore_branch. Branches protected by "
        "repository policy (default branches, release/*) are refused with an "
        "error naming the policy. A branch whose tip is not merged into the "
        "default branch is refused unless force is true; prefer reporting such a "
        "branch back to the user over setting force. Deleting a branch does not "
        "delete its open pull requests, which become unmergeable."
    ),
    inputSchema={
        "type": "object",
        "properties": {
            "repo": {"type": "string", "description": "Repository slug, e.g. platform-api."},
            "branch": {
                "type": "string",
                "description": "Exact branch name. Never a pattern or a prefix.",
            },
            "force": {
                "type": "boolean",
                "description": (
                    "Delete even if the branch has commits not merged into the "
                    "default branch. Defaults to false. Do not set this to work "
                    "around a refusal without being asked to."
                ),
            },
        },
        "required": ["repo", "branch"],
        "additionalProperties": False,
    },
    annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True),
)

That version costs about 190 tokens in the provider request against roughly 60 for the original, measured by sending both through the provider's token counting endpoint. The whole array of tools sits at the front of every request in the session, ahead of the system prompt and the conversation, so those 130 extra tokens are paid on every turn — with prompt caching in play, meaning the provider stores the unchanged front of the request and charges roughly a tenth to re-read it, they are paid at cache-read rates after the first turn, and the arithmetic almost always favours the longer version. A server with 34 tools at 190 tokens each is spending about 6,500 tokens on its listing, which is real but is a rounding error next to one wrong destructive call.

Both of those figures are arithmetic you can redo rather than facts you have to accept. The extra cost per tool is 190 minus 60, which is the 130 quoted above and is the whole of what is being bought and sold. The listing total is thirty-four tools each carrying the longer version, and thirty-four times a hundred and ninety lands a little under six and a half thousand tokens — which is where "about 6,500" comes from.

Why the cache applies to this particular cost, rather than to some other part of the request, is positional and worth following. A cache of this kind stores an unchanged prefix and charges a fraction to re-read it, so the discount reaches only what sits at the front and stays byte-identical from one turn to the next. The tool array satisfies both conditions by construction: it is the front, and it does not change while the session lasts. The conversation is the part that grows, it sits behind the array, and it is therefore the part that cannot be reused. So the thing an author actually chooses about — how much to write in a description — lands entirely inside the cacheable region, and the thing nobody chooses about lands entirely outside it.

It is worth doing that arithmetic once with numbers attached rather than trusting the intuition, because the intuition that descriptions are expensive is common and is usually wrong by an order of magnitude. Take a session of forty turns against a server with a dozen tools, priced at a representative $3 per million input tokens with cached reads at a tenth of that:

Terse descriptionsFull descriptions
Tokens per tool~60~190
Tool array, 12 tools7202,280
First turn, uncached$0.0022$0.0068
39 further turns, cache-read$0.0084$0.0267
Session total for the listing$0.011$0.033
Cost of one wrongly deleted branch—one engineer, half a day

The table is small enough to rebuild from its inputs, and rebuilding it once settles the intuition permanently. A dozen tools at sixty tokens each is the 720 in the second row; the same dozen at a hundred and ninety is the 2,280 beside it. At three dollars per million input tokens, seven hundred and twenty tokens is seven hundred and twenty millionths of three dollars, which comes to about a fifth of a cent — the uncached first turn. The remaining thirty-nine turns re-send the same array at cache-read rates, a tenth of the headline price, so thirty-nine lots of seven hundred and twenty tokens is a little over twenty-eight thousand tokens charged at thirty cents per million, which is most of a cent. Add the two and the terse listing costs about a cent for the whole session.

Run the identical arithmetic on 2,280 and every figure moves by the same factor, a little over three, because the token count is the only input that changed — which is why the full listing lands a bit over three cents rather than at one. Subtract the totals and the difference is a little over two cents.

Two cents a session is the price of the rewrite, and it buys the difference between a model that has been told about the seven-day recovery window and one that has not. The reason to keep descriptions tight is not the budget: a model reading 2,280 tokens of tool documentation has more to weigh when it chooses, which is a selection problem, and the seventh chapter of this topic is where that shows up.

The property descriptions matter more than their length suggests, because they are the only place the argument-level constraints can be stated. The instruction not to set force to work around a refusal is there because the observed failure mode was exactly that: refused, retried with a variation. Whether the model obeys is a separate question, and the answer is not "reliably" — which is why the fourth chapter of this topic, on enforcement in the handler, exists — but an instruction that was never sent has no chance at all.

The schema crosses too, and it fails in its own way

The description gets most of the attention because it reads like prose and prose feels like the interface. The input schema crosses the same boundary, is read by the same model, and carries a failure mode of its own that is harder to see because it produces plausible-looking calls rather than obviously wrong ones. The original delete_branch schema declared three string and boolean properties, required two of them, and said nothing else — no additionalProperties, no pattern, no enum, no property descriptions. What arrived at the handler during a cleanup session included this:

json
{
  "name": "mcp__git__delete_branch",
  "input": {
    "repo": "platform-api",
    "branch": "release/2026-q1-backup",
    "force": true,
    "reason": "unmerged; user asked for cleanup of merged branches only"
  }
}

The model invented reason, which is a sensible thing to do when a schema has not said the object is closed and the model has something it wants to record. The handler's signature had no such parameter, the SDK passed the arguments dictionary through, and the key was dropped without anybody noticing. Nothing failed. The interesting part is what the invented key implies about the call it accompanied: the model had reservations about setting force, wrote them into a field it made up, and proceeded anyway, and none of that reached a human because the field went into a dictionary nobody read.

Three separate things had to hold for that to be silent, and each is worth checking in your own server, because removing any one of them makes the divergence visible. The schema did not declare the object closed, and an object that says nothing about additional properties is, by the rules of JSON Schema, one that permits them — so nothing upstream had grounds to reject the extra key. The SDK hands the handler its arguments as a dictionary rather than as named parameters, so an unexpected key is not a call-signature error; it is a key nobody looks up. And the handler reads the keys it wants by name, so an unread key produces no behaviour at all, not even a warning. additionalProperties: false removes the first of the three, and it is the cheapest of the three to remove.

Setting "additionalProperties": false turns that into a validation error at the client or the server, depending on which one validates first, and the error is worth having because it converts a silent divergence between what the model believed it was sending and what the handler received into a visible one. The other schema keywords earn their place for the same reason. A pattern on an identifier stops the model from passing a glob where an exact name is required, an enum on a status field stops it from inventing a plausible fifth value, and a maxLength on a free-text field stops a summarisation tool from being handed the whole document. Each is a sentence you would otherwise have to write in the description and hope was read, expressed in a form that a validator enforces before the handler runs. None of this is validation in the security sense, which the fourth chapter takes up. It is the difference between a contract that says three things and a contract that says twelve, both costing about the same to send.

Rendering is the client's decision, not yours

The phrase "as the client chose to render them" is doing real work. Hosts differ in how they translate a listing, and the differences are visible in captured requests: some prefix names with the server name, some prefix with a shortened alias, some leave names untouched and reject a server whose tool name collides with an existing one. Some concatenate the tool's title annotation into the description; some ignore title entirely and use it only in their own UI. At least one truncates descriptions past a fixed length. When a host supports tool-count limits or deferred loading, your tool may be present in the connection and absent from a given request.

None of that is under your control, and the practical consequence is that "what the model saw" is a per-host question with a per-host answer. The habit worth forming is to capture the provider request from every host you support at least once, rather than reasoning from the listing your server emits. It takes a few minutes, and it is the difference between knowing and assuming on the one question that determines whether a tool gets used correctly.

Porting a tool onto MCP

The tool in this chapter existed before the git server did. It was a function in an internal agent's own tool definitions, with the same name, the same one-line description and the same three parameters, and it was moved onto an MCP server so that three other applications could use it. It is worth being exact about what that move changed, because the expectation that it changed the contract is common and expensive.

Moving a tool onto MCP gives you a transport, a discovery mechanism, a process boundary, an authorization surface at that boundary, and reuse across every host that speaks the protocol. Those are substantial, and they are the reason to do it. What the move does not touch is the contract: after the port, the tool is still called delete_branch, still described as Delete a branch., and still takes three loosely typed parameters. The same vague description now travels to three model providers instead of one, unchanged, and each of them will make the same class of mistake with it. MCP standardises how capability is connected, not whether the capability was worth connecting.

This is the shape of a whole category of disappointment with the protocol. A team adopts MCP, expects the standardisation to improve behaviour, and finds the same misfires with more infrastructure around them. The protocol did what it promised; the tool contract was never in scope.

Everything that decides whether a model calls the right tool with the right arguments is a property of three strings, and those three strings crossed unchanged. That is the whole finding, and the practical version of it is a habit rather than a rule: before arguing about why a tool behaved badly, read what the model was given, from the request the host actually sent, for the host it actually happened on.

There is one field in the earlier table that had a hedged answer — the destructive annotation, which is sent to the client but whose fate after that is the host's business. That hedge is not a detail. A large amount of misplaced confidence rests on those annotations, including the belief that a destructive flag was going to make a host stop and ask before a release branch went away.

Tools over MCP — read the current version at https://daviddominguez.dev/learn/en/tools-over-mcp/the-name-description-and-schema

Back to top