Designing an MCP tool surface comes down to four decisions: what goes in the description, how much structure you dare put in the parameter schema, how many bytes come back per call, and what an error message must contain for the model to fix itself. The first two decide whether the model picks the right tool and fills it in correctly; the last two decide whether the agent still has usable context on turn ten. The current spec revision is 2026-07-28, published July 28, 2026, and it changed the first two materially: inputSchema was lifted from a three-field TypeScript type to full JSON Schema 2020-12. What did not change is the subset your model provider actually honors — and that gap is where most of this article lives.
What 2026-07-28 changed for tool authors
SEP-2106 rewrote the Tool.inputSchema type from
inputSchema: { type: "object"; properties?: {...}; required?: string[] }
to { $schema?: string; type: "object"; [key: string]: unknown }. The root is still required to be type: "object" — tool arguments are always JSON objects — but everything else in the 2020-12 vocabulary is now legal: composition keywords (oneOf, anyOf, allOf, not), conditionals (if/then/else), and references ($ref, $defs, $anchor). The SEP's motivating example is one everybody has hit: a lookup accepting either an ID or a name.
outputSchema is looser still — any valid 2020-12 schema, not just an object; structuredContent is typed unknown and may be "any JSON value (object, array, string, number, boolean, or null)." The trap is assuming this expressiveness survives the trip to the model.
The schema you write is not the schema the model sees
A client translates your inputSchema into whatever format its model provider accepts, and those formats are narrower. Anthropic's strict tool use does not support recursive schemas, numeric constraints (minimum, maximum, multipleOf), string-length constraints (minLength, maxLength), or array constraints beyond minItems of 0 or 1 — and requires additionalProperties: false on every object (JSON Schema limitations). Use one of those keywords with strict: true and the request comes back as a 400 rather than quietly losing the constraint.
SEP-2106 flags the mirror-image risk on the output side: older clients are compatible "only when the server returns object-typed structuredContent" — arrays and primitives may break them absent an explicit fallback in content.
The operating rule falls out directly: write the constraints, but never make correctness depend on them. Both modes leak in their own way. With strict, an unsupported keyword is rejected outright. Without it, "minimum": 1 rides along in the tool definition as a hint to the model and nothing enforces it. Either way, the model can hand you -3. Your server validates everything itself — which is what the spec's security section demands in as many words: servers MUST validate all tool inputs.
$ref is legal now, but not across the network
The spec draws a hard line: 2020-12 permits $ref to an absolute URI, but implementations MUST NOT automatically dereference a $ref resolving to a network URI. An opt-in fetching mode is allowed but MUST default to off and SHOULD enforce a host allowlist — at minimum rejecting loopback, link-local, and private-network addresses — with timeouts, size limits, and logging. A schema failing validation on an unresolved external $ref SHOULD be rejected rather than treated as permissive. The motivation: an unrestricted $ref turns every MCP client into an SSRF gadget — one of several egress paths worth closing off server-side (MCP server security checklist). Use $defs locally; treat remote $ref as nonexistent.
An easily-missed constraint follows: composition keywords and $defs "can be expensive to validate," so implementations SHOULD bound depth, subschema count, or per-validation time to stop a malicious schema acting as a DoS vector against the validator. Your seven-level allOf + if/then/else may be rejected outright.
Tool names: two rule sets that disagree
The spec says names SHOULD be 1–128 characters, using ASCII letters, digits, underscore, hyphen, and dot. Its own example list includes admin.tools.list. Anthropic's API requires names to match ^[a-zA-Z0-9_-]{1,64}$ — dots illegal, ceiling halved. And because name uniqueness is scoped to a single server, clients aggregating servers SHOULD disambiguate by prefixing with a server identifier, out of that same 64-character budget.
Practical rule: no dots, names under about 50 characters, namespace by service with underscores (github_list_prs, slack_send_message). Anthropic recommends that namespacing independently — it keeps selection unambiguous as the library grows and lets one search match a whole family.
Descriptions are routing metadata, not documentation
Anthropic's guidance is unusually blunt: detailed descriptions are "by far the most important factor in tool performance," and you should aim for at least 3–4 sentences per tool. Cover what it does, when it should be used and when it shouldn't, what each parameter means, and any caveats — explicitly including what the tool does not return. That last item is load-bearing and routinely skipped: a model that doesn't know get_stock_price returns only the price will call it hoping for a market cap, get a number, and confabulate the rest.
More valuable than "what it does" is when to call it. Since the docs ask for when it should be used and when it shouldn't, spell the trigger condition out as a sentence — "Call this when the user asks about current prices or recent events" — instead of only stating functionality.
Just don't reach for emphasis to get there. Recent-generation models are more responsive to instructions than their predecessors, not less. Anthropic's prompting guide is explicit: prompts written to fix under-triggering on older models now over-trigger on Opus 4.5 and 4.6, and the fix is to dial the aggressive language back — CRITICAL: You MUST use this tool when... becomes Use this tool when.... Tools that under-triggered on previous models are likely to trigger appropriately now. When a tool fires that shouldn't, soften the language rather than adding another guardrail sentence.
What does not belong: worked dialogue examples, numbered workflows, embedded protocols. These cost tokens on every request and pin the model's exploration space. To teach the shape of a complex input, use a provider-side mechanism (Anthropic's input_examples: ~20–50 tokens simple, 100–200 nested) or move it into a prompt or skill layer.
Granularity: cut along task lines, not endpoint lines
The default mistake is one tool per REST endpoint. Anthropic's engineering write-up gives the canonical counterexample: instead of list_users, list_events, and create_event for the model to chain, ship a schedule_event that finds availability and books in one call. Tools should match how a human would subdivide the task, because every intermediate result lands in the context window first.
The docs also suggest consolidating related operations behind one tool with an action parameter (create_pr / review_pr / merge_pr → one tool) to reduce selection ambiguity. A tension worth naming: consolidation lowers selection cost but pushes the schema toward oneOf and if/then/else — exactly what the model side supports worst. The workable middle is to consolidate the action and keep parameters flat — an action enum plus optional fields, validated server-side per action, not a discriminated union that gets flattened or rejected downstream.
There is also a hard ceiling on how many tools you can expose. Anthropic's tool-search docs put numbers on it: selection accuracy "degrades once you exceed 30–50 available tools," and a typical multi-server setup (GitHub, Slack, Sentry, Grafana, Splunk) consumes roughly 55k tokens in definitions before the model does any work. Thresholds for on-demand loading: 10 or more tools, or definitions over 10k tokens; always-loaded calling is better below 10 tools or under 100 tokens total.
The tools/list ordering detail that quietly costs money
2026-07-28 added ttlMs and cacheScope to list responses (SEP-2549). ttlMs is milliseconds, "analogous to HTTP Cache-Control: max-age": 0 means immediately stale, absent and negative are both treated as 0, servers MUST send >= 0. It is a freshness hint, and clients SHOULD NOT treat it as a polling interval — the same hint-not-guarantee semantics HTTP caching has always had, whose sharp edges are covered in ETags, preconditions, and CDN caching for APIs.
The part that matters more sits next to it. Servers SHOULD return tools in a deterministic order, for two stated reasons: it lets clients reliably cache the tool list, and it "improves LLM prompt cache hit rates when tools are included in model context." Tool definitions render at the very front of the prompt prefix, so if your tools/list iterates a hash map and the order shuffles, you invalidate the prompt cache for everything downstream — system prompt and entire conversation. Sort by name; it costs nothing.
cacheScope has two values, and its security note deserves reading twice. "public" means any client, gateway, or caching proxy MAY serve the response to any user — including across authorization contexts, even from an authenticated endpoint. Since the tool set MAY vary by the authorization on the request, a permission-filtered list marked public is a straightforward privilege leak. Filtered lists are private, and cacheScope must match across all pages.
Response size and the context budget
This is where agents actually die. Measured anchors: Anthropic reports the same Slack data rendered concisely versus in full at roughly 72 versus 206 tokens — about 3×. Claude Code truncates tool responses at 25,000 tokens by default. On the Managed Agents side, output above 100,000 characters (roughly 25,000 tokens) is automatically offloaded to a file, with the model receiving a truncated preview plus the path.
- Default to concise, let the model opt into detail via a
response_format: "concise" | "detailed"enum. Cheapest lever available. - Give pagination and
limitsensible defaults. Don't rely on the model to pass them. - Return semantically stable identifiers — slugs, UUIDs — not opaque internal references. The model reasons about its next call with whatever you hand it.
- Emit both
structuredContentandcontent. Declaring anoutputSchemameans servers MUST return conformingstructuredContent, and should also return the serialized JSON in aTextContentblock for compatibility. The payload sits in context twice — one more reason to keep it small.
The stateless core adds a related surface: with no protocol session, cross-call state needs an explicit handle returned from a creation tool and passed back as an argument. The spec's guidance is good and rarely followed — handles should be opaque, authorization revalidated on every call ("a handle is a name, not a capability"), and the retention policy stated in the creation tool's description ("baskets expire after 24 hours of inactivity") so the model sees it before creating state.
Missing parameters: stop making the model guess
MRTR (multi round-trip requests, SEP-2322) opens a third option beyond marking a parameter required — and watching the model invent a value it cannot know — or marking it optional and not getting it.
A server can now return resultType: "input_required" with an inputRequests map carrying an elicitation/create request and a requestedSchema. The client collects the answer and retries the same tools/call with inputResponses and the server-supplied requestState. Two mechanics: the retry's JSON-RPC id MUST differ from the original, and results from this path MUST NOT be cached, since they depend on inputs outside the cache key.
So anything only the user can know should go through elicitation, not required — account selection, target environment, destructive-action confirmation. Making those required parameters is how a hallucination becomes a side effect.
Errors: isError is the self-correction channel
MCP separates two error mechanisms and the distinction has teeth.
Protocol errors are JSON-RPC error responses — unknown tool, malformed request, server failure. The spec calls these "issues with the request structure itself that models are less likely to be able to fix"; clients MAY pass them to the model.
Tool execution errors go in the result with isError: true — API failures, validation errors, business logic errors. These "contain actionable feedback that language models can use to self-correct and retry with adjusted parameters"; clients SHOULD provide them to the model.
Getting this backwards is a common, expensive bug. Throw a validation failure as a JSON-RPC error and many clients treat it as a transport fault the model never sees; the model then retries the identical bad call. The schema source is unambiguous: tool-originated errors belong in the result with isError: true, not as a protocol-level error, "otherwise the LLM would not be able to see that an error occurred and self-correct."
The spec's own example is worth copying structurally:
Invalid departure date: must be in the future. Current date is 08/08/2025.
Three elements: which parameter is wrong, what the constraint is, and the current correct reference value. That third one is what most error messages omit — "date must be in the future" doesn't tell a model what today is. Same for expired handles: say "basket bsk_a1b2c3 has expired; call create_basket to start a new one," not a bare 404.
Annotations are hints, and the spec says so loudly
ToolAnnotations survives in 2026-07-28. The defaults are easy to misremember, so here they are from the schema source:
| Field | Default | Meaning |
|---|---|---|
readOnlyHint |
false |
True if the tool does not modify its environment |
destructiveHint |
true |
True if it may perform destructive updates (meaningful only when readOnlyHint == false) |
idempotentHint |
false |
Repeated calls with the same arguments have no additional effect |
openWorldHint |
true |
True if it interacts with an open world of external entities |
destructiveHint and openWorldHint default to true — omit them and you are declaring "assume dangerous, assume networked." The direction is correct, but you must explicitly mark read-only tools or confirmation prompts will bury your users. idempotentHint is likewise only a claim: what makes a retry actually safe is server-side deduplication (The Idempotency-Key Header).
The schema comment is emphatic: all properties are hints, "not guaranteed to provide a faithful description of tool behavior (including descriptive properties like title)," and clients "should never make tool use decisions based on ToolAnnotations received from untrusted servers." The tools page repeats it normatively.
Do not build a permission model on annotations. Authorization belongs server-side — the tool set MAY vary by the authorization presented on the request.
Shipping checklist
- No dots in tool names, under ~50 characters, namespaced by service with underscores.
- 3–4 sentences per description: when not to use it, what it does not return, triggers stated declaratively.
- Write constraints into the schema, then validate independently server-side.
- Local
$defsonly, no cross-document$ref, composition shallow. - Define tools by task, not endpoint. Consolidate actions, keep parameters flat.
- Past ~10 tools or ~10k tokens of definitions, move to on-demand loading.
- Sort
tools/listdeterministically, setttlMs, mark filtered listsprivate. - Default responses to concise; ship
response_formatand pagination defaults. - Route recoverable failures through
isError: true, with the correct current reference value. - Set
readOnlyHintexplicitly — and put zero authorization logic in annotations.
One forward-looking note: 2026-07-28 deprecated Roots, Sampling, and Logging (SEP-2577) with a twelve-month minimum support window. If your tool design leans on Sampling for a server-initiated model call, start redrawing now.