Skip to content

Tools

A tool is a function the language model can decide to call. The server describes it with a name, a description, and a JSON Schema for its input; the host passes that description to the model; the model emits a call; the host forwards it as tools/call and feeds the result back into the conversation.

sequenceDiagram
  participant M as LLM
  participant C as Client / Host
  participant S as Server
  C->>S: tools/list
  S-->>C: tool definitions (ttlMs, cacheScope)
  C->>M: definitions become the model's tool list
  M->>C: call search_catalog(query: "mug")
  C->>S: tools/call name=search_catalog
  S-->>C: content[] (+ structuredContent)
  C->>M: result returned as context
Discovery, selection, invocation

Because the model chooses, the description and schema are not documentation — they are the interface. A vague description or a loose schema produces wrong calls. Design them as carefully as a public API.

{
"name": "search_catalog",
"title": "Search the product catalog",
"description": "Find products by a case-insensitive substring of their name. Returns at most `limit` matches (default 10, max 50).",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Substring to match against product names" },
"limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 }
},
"required": ["query"]
}
}
FieldRequiredPurpose
nameyesUnique identifier within this server; what the model calls
titlenoHuman-readable label for UIs
descriptionno (but always write one)What it does, when to use it, what it returns
inputSchemayesJSON Schema (2020-12 by default) for arguments; MUST be a valid schema object, never null
outputSchemanoSchema for structuredContent — see the next lesson
annotationsnoBehavior hints (readOnlyHint, destructiveHint, …)
iconsnoVisual identifiers for UIs

A tool with no parameters should still declare a schema. The recommended form only accepts an empty object:

{ "name": "get_current_time", "inputSchema": { "type": "object", "additionalProperties": false } }
  • 1–128 characters, case-sensitive, unique within the server.
  • Only ASCII letters, digits, _, -, and . — no spaces or commas. getUser, DATA_EXPORT_v2, and admin.tools.list are all valid.
  • Uniqueness is scoped to one server. A host that aggregates many servers may see two search tools; it is the host’s job to disambiguate, typically by prefixing with a server identifier. Do not rely on serverInfo.name for that — it is self-reported and not unique.

Pick names the model can read: search_catalog beats sc, and create_issue beats issue (which could be read or create).

Discovery is a plain request with an optional cursor:

{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "cursor": "optional" } }
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"tools": [ { "name": "search_catalog", "...": "..." } ],
"nextCursor": "next-page-cursor",
"ttlMs": 300000,
"cacheScope": "public"
}
}

Three things to notice:

  • Pagination. If nextCursor is present, call again with it as cursor. Cursors are opaque.
  • Caching. ttlMs says how long the list may be cached; cacheScope says whether a shared intermediary may cache it (public) or only this caller (private). Both are required on list results in the 2026-07-28 revision.
  • Deterministic order. Servers SHOULD return tools in the same order every time the set is unchanged. Hosts put tool definitions into the model’s prompt prefix, and a reshuffled list breaks LLM prompt caching.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "search_catalog", "arguments": { "query": "mug", "limit": 5 } }
}
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"content": [ { "type": "text", "text": "Travel mug\nEspresso cup" } ],
"isError": false
}
}

(Both examples omit the per-request _meta fields for brevity; every real request carries them — see Foundations.)

content is an array, so a single result can mix types:

typeFieldsUse for
texttextThe default; what the model reads
imagedata (base64), mimeTypeScreenshots, charts
audiodata (base64), mimeTypeRecordings, TTS output
resource_linkuri, name, description, mimeTypePoint at a resource without embedding it (not guaranteed to appear in resources/list)
resourceresource: { uri, mimeType, text or blob }Embed a resource’s contents inline

Every content block may carry annotations (audience, priority, lastModified) — the same shape resources use.

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const catalog = [
{ name: 'Espresso cup', price: 12 },
{ name: 'Travel mug', price: 24 },
];
const server = new McpServer({ name: 'catalog', version: '1.0.0' });
server.registerTool(
'search_catalog',
{
title: 'Search the product catalog',
description: 'Find products by a case-insensitive substring of their name.',
inputSchema: z.object({
query: z.string().describe('Substring to match against product names'),
limit: z.number().int().min(1).max(50).optional(),
}),
},
async ({ query, limit }) => {
const hits = catalog.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()));
if (hits.length === 0) {
// execution error: the model can read this and try again
return { content: [{ type: 'text', text: `No products match "${query}". Try a shorter substring.` }], isError: true };
}
const names = hits.slice(0, limit ?? 10).map((p) => p.name);
return { content: [{ type: 'text', text: names.join('\n') }] };
},
);

The Zod schema is converted to JSON Schema for tools/list and used to validate arguments before your handler runs; a schema violation becomes a protocol error, not a call into your code.

Tools have two distinct ways to fail, and choosing the right one matters because they reach the model differently.

Protocol errors are JSON-RPC errors: the request itself was wrong. Unknown tool name, arguments that fail inputSchema, an internal crash. The model is unlikely to fix these by rephrasing, and clients MAY hide them from it.

{ "jsonrpc": "2.0", "id": 3, "error": { "code": -32602, "message": "Unknown tool: search_catalogue" } }

Tool execution errors are successful responses with isError: true. The call was well-formed; the operation failed for a reason the model can act on — a date in the past, a missing record, an upstream API refusing. Clients SHOULD show these to the model so it can self-correct.

{
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "complete",
"content": [ { "type": "text", "text": "Invalid departure date: must be in the future. Current date is 2026-09-10." } ],
"isError": true
}
}

The design rule: make execution errors actionable. “Error 500” teaches the model nothing; “limit must be between 1 and 50, you sent 200” lets it retry correctly on the next turn. Include the constraint, the offending value, and, when useful, the current state (like the date above).

MCP has no protocol-level session, so a tool cannot rely on “what this client did earlier”. If you need state across calls — a shopping basket, a browser tab, a transaction — mint an explicit handle and pass it back and forth as ordinary arguments:

{ "name": "create_basket", "arguments": {} }
{ "content": [ { "type": "text", "text": "Created basket bsk_a1b2c3" } ], "structuredContent": { "basket_id": "bsk_a1b2c3" } }
{ "name": "add_item", "arguments": { "basket_id": "bsk_a1b2c3", "sku": "MUG-24" } }

The model carries basket_id forward. When designing handles: make them opaque and high-entropy, bind them server-side to the authenticated user (a handle is a name, not a credential), state their lifetime in the creation tool’s description (“baskets expire after 24 hours”), and return a clear execution error on an expired handle so the model can create a new one.

The spec makes these MUSTs for servers: validate all inputs, implement access controls, rate-limit invocations, sanitize outputs. And it reminds hosts that there should always be a human able to deny a tool call — which is why your annotations matter.

Which tool name is INVALID under the spec's naming rules?
A tool is called with a well-formed request, but the requested record does not exist. How should the server report it?
Why should tools/list return tools in a deterministic order?
How should a server keep a shopping cart across several tool calls?