MCP Tool Names and Descriptions
The model decides whether to call a tool from its name, its description and the descriptions of its parameters. Give each tool a specific name with a prefix for the service or resource it works on, such as ticket_search rather than search. The MCP spec says names should use only ASCII letters, digits, underscores, hyphens and dots, and be unique within your server. In the description, say what the tool does, when to use it, what it returns, and which tool to use instead when it doesn't fit.
Before you start
- Python: Python 3.10 or later and uv.
- TypeScript: Node.js 22.19 or later and npm. MCP Inspector declares
node >=22.19.0in its npm package. - Some familiarity with building an MCP server. This guide only changes the tool definitions, so it skips project setup.
How the model reads a tool
When an AI app connects to your server, it calls tools/list. The server replies with each tool's name, description and inputSchema, which is the JSON Schema for its parameters. The app passes those definitions to the model. When the model wants to act, it picks a tool by name, fills in the arguments, and the app sends tools/call.
The model never sees your function body. The name and description are the only guidance it gets, so they do most of the work. Anthropic's guidance says tool descriptions are "loaded into your agents' context," where they steer how tools get used. The spec describes the description field as a human-readable description of functionality, and its schema notes that it can be thought of like a hint to the model.
The naming rules in the spec
The 2026-07-28 spec sets these rules for tool names. They're written as SHOULD, not MUST, so treat them as the baseline for every server you build.
| Rule | What the spec says |
|---|---|
| Length | Between 1 and 128 characters |
| Characters | Only A-Z, a-z, 0-9, _, - and .. No spaces, commas or other special characters |
| Case | Case-sensitive, so getUser and getuser are different tools |
| Uniqueness | Unique within one server |
| Examples | getUser, DATA_EXPORT_v2, admin.tools.list |
Uniqueness applies to one server only. A client that combines several servers can end up with two search tools. The spec says such a client should use a disambiguation strategy, such as prefixing tool names with a server identifier, and it warns that the server's serverInfo name isn't guaranteed to be unique, so don't rely on it for that. Your tool names should still read clearly when another server's tools sit next to them.
Two more reasons to get names right:
- Over Streamable HTTP, the tool name is also sent in an
Mcp-Nameheader, so gateways can route on it. The 2026-07-28 release post describes this. - If you plan to submit a server to the Anthropic Directory, the Anthropic tool design reference says its review requires tool names of 64 characters or fewer.
Name tools so they sort and separate
Names are where a model first tells two tools apart. A few habits help.
- Prefix with the service or resource. Anthropic's documentation recommends meaningful namespacing, such as
github_list_prsorslack_send_message. Its tool-writing post also says that prefix and suffix namespacing change tool-use results depending on the model, so test whichever you pick. Every tool in the example below starts withticket_. - Use one verb for one action. Pick
ticket_searchorticket_findand use it everywhere. Mixing them makes the model guess whether they're different. - Avoid bare words. Anthropic's post warns that overlapping or vague tools confuse agents about which to use.
search,get,updateanddeletecould mean anything, in any server. - Make the name match what happens. A tool called
deleteshould delete. If it actually closes a record or keeps a copy, name it for that. - Keep names stable. Renaming a tool breaks any prompt, saved workflow or client setting that calls it by name. Add a new tool and retire the old one, rather than renaming in place.
Write descriptions the model can act on
Anthropic's documentation calls detailed descriptions by far the most important factor in tool performance. It asks for descriptions that explain what the tool does, when to use it and when not to, what each parameter means, and any caveats. It suggests at least three or four sentences for each tool, more if the tool is complex. That's Anthropic's advice for Claude rather than a spec rule, but it's a good starting point for any model.
A description that works usually covers four things, in roughly this order:
- What it does, in plain words that match how users talk.
- When to use it, with the kinds of requests that should trigger it.
- What it returns, so the model knows what to expect and what to do next.
- What it doesn't do, and which tool to use instead. Anthropic's reference suggests that each description should say when to use the other tool when two tools are similar.
Describe every parameter too. In Python, put the text in Annotated[..., Field(description=...)]. In TypeScript, add .describe() to each Zod field. The build guide shows both in context, and tool input schemas covers types, enums and defaults.
Keep descriptions to what the model needs to choose correctly. The description and schema for every tool go into the model's context, so padding costs tokens on every request.
Compare a bad server with a good one
The example is a small support-ticket server that searches tickets, reads one, adds a comment and closes a ticket. It keeps its data in a JSON file, set by the TICKETS_FILE environment variable. The bad version can do the same kinds of things, but with generic names and one-line descriptions.
- Python
- TypeScript
import logging
from typing import Annotated
from mcp.server import MCPServer
from pydantic import Field
from store import find_ticket, load_tickets, save_tickets
# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("tickets")
mcp = MCPServer("tickets")
@mcp.tool()
def search(q: str, n: int = 10) -> str:
"""Search."""
found = [t for t in load_tickets() if q.lower() in t["subject"].lower()][:n]
return "\n".join(f"{t['id']} {t['subject']}" for t in found) or "none"
@mcp.tool()
def get(id: str) -> str:
"""Get item."""
return str(find_ticket(load_tickets(), id))
@mcp.tool()
def update(id: str, data: str) -> str:
"""Update item."""
tickets = load_tickets()
find_ticket(tickets, id)["comments"].append({"author": "", "text": data})
save_tickets(tickets)
return "ok"
@mcp.tool()
def delete(
id: Annotated[str, Field(description="id")],
) -> str:
"""Delete."""
save_tickets([t for t in load_tickets() if t["id"] != id])
logger.info("Deleted %s", id)
return "done"
if __name__ == "__main__":
mcp.run(transport="stdio")
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
import { findTicket, loadTickets, saveTickets } from "./store.js";
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "tickets", version: "1.0.0" });
server.registerTool(
"search",
{
description: "Search.",
inputSchema: z.object({
q: z.string(),
n: z.number().optional(),
}),
},
async ({ q, n }) => {
const found = loadTickets()
.filter((t) => t.subject.toLowerCase().includes(q.toLowerCase()))
.slice(0, n ?? 10);
return text(found.map((t) => `${t.id} ${t.subject}`).join("\n") || "none");
},
);
server.registerTool(
"get",
{
description: "Get item.",
inputSchema: z.object({
id: z.string(),
}),
},
async ({ id }) => text(JSON.stringify(findTicket(loadTickets(), id))),
);
server.registerTool(
"update",
{
description: "Update item.",
inputSchema: z.object({
id: z.string(),
data: z.string(),
}),
},
async ({ id, data }) => {
const tickets = loadTickets();
findTicket(tickets, id).comments.push({ author: "", text: data });
saveTickets(tickets);
return text("ok");
},
);
server.registerTool(
"delete",
{
description: "Delete.",
inputSchema: z.object({
id: z.string().describe("id"),
}),
},
async ({ id }) => {
saveTickets(loadTickets().filter((t) => t.id !== id));
console.error(`Deleted ${id}`);
return text("done");
},
);
return server;
}
serveStdio(createServer, {
onerror: (error) => console.error("Tickets server error:", error),
});
Here's what a model gets from that server, summarized from what MCP Inspector returns for tools/list:
| Name | Description | Parameters |
|---|---|---|
search | Search. | q, n (no descriptions) |
get | Get item. | id (no description) |
update | Update item. | id, data (no descriptions) |
delete | Delete. | id (described only as "id") |
The model has to guess what q searches, whether update replaces or adds to something, and whether delete is permanent. The description for delete doesn't say.
The good version fixes all of that. Both servers import the same storage helpers, in store.py or store.ts, so the difference is only in the tool definitions:
- Python
- TypeScript
import json
import os
from pathlib import Path
from typing import Any
from mcp.server.mcpserver.exceptions import ToolError
TICKETS_FILE = Path(os.environ.get("TICKETS_FILE", Path.home() / ".tickets-mcp.json"))
def load_tickets() -> list[dict[str, Any]]:
if not TICKETS_FILE.exists():
return []
return json.loads(TICKETS_FILE.read_text())
def save_tickets(tickets: list[dict[str, Any]]) -> None:
TICKETS_FILE.write_text(json.dumps(tickets, indent=2))
def find_ticket(tickets: list[dict[str, Any]], ticket_id: str) -> dict[str, Any]:
for ticket in tickets:
if ticket["id"] == ticket_id:
return ticket
raise ToolError(
f"No ticket with ID {ticket_id}. Use ticket_search to find the right ID."
)
import logging
from typing import Annotated, Literal
from mcp.server import MCPServer
from pydantic import Field
from store import find_ticket, load_tickets, save_tickets
# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("tickets")
mcp = MCPServer("tickets")
Status = Literal["open", "pending", "closed"]
TicketId = Annotated[
str, Field(description="The ticket ID, such as T-1001. Get IDs from ticket_search.")
]
def _summary(ticket: dict) -> str:
return f"{ticket['id']} [{ticket['status']}] {ticket['subject']} (customer: {ticket['customer']})"
def _matches(ticket: dict, words: list[str], status: Status | None) -> bool:
if status is not None and ticket["status"] != status:
return False
text = " ".join(
[ticket["subject"], ticket["customer"], *(c["text"] for c in ticket["comments"])]
).lower()
return all(word in text for word in words)
@mcp.tool()
def ticket_search(
query: Annotated[
str,
Field(
description="Words to find in the ticket subject, customer name or comments. Case-insensitive. Pass an empty string to list tickets without a text filter."
),
],
status: Annotated[
Status | None,
Field(description="Only return tickets with this status. Leave out to include all statuses."),
] = None,
limit: Annotated[
int,
Field(description="Maximum number of tickets to return, from 1 to 50.", ge=1, le=50),
] = 10,
) -> str:
"""Search support tickets by words in the subject, customer name or comments.
Use this when the user asks to find, look up or list tickets, or asks what
tickets a customer has. It returns one line per ticket with its ID, status
and subject. To read a ticket in full, call ticket_get with its ID.
"""
words = query.lower().split()
matches = [t for t in load_tickets() if _matches(t, words, status)][:limit]
if not matches:
return "No tickets match. Try fewer words or remove the status filter."
return "\n".join(_summary(t) for t in matches)
@mcp.tool()
def ticket_get(ticket_id: TicketId) -> str:
"""Get one support ticket in full: its status, customer and every comment in order.
Use this when the user asks for the details of a ticket or quotes a ticket ID
such as T-1001. It returns an error if no ticket has that ID, so search first
if you're not sure of the ID.
"""
ticket = find_ticket(load_tickets(), ticket_id)
lines = [_summary(ticket), ""]
lines += [f"{c['author']}: {c['text']}" for c in ticket["comments"]]
return "\n".join(lines)
@mcp.tool()
def ticket_add_comment(
ticket_id: TicketId,
author: Annotated[
str,
Field(min_length=1, description="Name of the person writing the comment, such as 'Dana (support)'."),
],
text: Annotated[
str,
Field(min_length=1, description="The comment text, written as it should appear on the ticket."),
],
) -> str:
"""Add a comment to an existing support ticket.
Use this when the user wants to reply to a customer, leave an internal note
or record progress on a ticket. It doesn't change the ticket's status. To
close a ticket, use ticket_close instead.
"""
tickets = load_tickets()
ticket = find_ticket(tickets, ticket_id)
ticket["comments"].append({"author": author, "text": text})
save_tickets(tickets)
logger.info("Added comment to %s", ticket_id)
return f"Added comment to {ticket_id}. The ticket now has {len(ticket['comments'])} comments."
@mcp.tool()
def ticket_close(
ticket_id: TicketId,
resolution: Annotated[
str,
Field(
min_length=1,
description="One sentence saying how the issue was resolved. It's saved as the last comment on the ticket.",
),
],
) -> str:
"""Close a support ticket once the customer's issue is resolved.
Use this only when the user says the issue is fixed or asks to close the
ticket. It sets the status to closed and adds the resolution as a comment.
This server has no tool to reopen a ticket.
"""
tickets = load_tickets()
ticket = find_ticket(tickets, ticket_id)
ticket["status"] = "closed"
ticket["comments"].append({"author": "support", "text": f"Resolved: {resolution}"})
save_tickets(tickets)
logger.info("Closed %s", ticket_id)
return f"Closed {ticket_id}."
if __name__ == "__main__":
mcp.run(transport="stdio")
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type Status = "open" | "pending" | "closed";
export interface Comment {
author: string;
text: string;
}
export interface Ticket {
id: string;
subject: string;
customer: string;
status: Status;
comments: Comment[];
}
const TICKETS_FILE =
process.env.TICKETS_FILE ?? join(homedir(), ".tickets-mcp.json");
export function loadTickets(): Ticket[] {
if (!existsSync(TICKETS_FILE)) return [];
return JSON.parse(readFileSync(TICKETS_FILE, "utf8")) as Ticket[];
}
export function saveTickets(tickets: Ticket[]): void {
writeFileSync(TICKETS_FILE, JSON.stringify(tickets, null, 2));
}
export function findTicket(tickets: Ticket[], ticketId: string): Ticket {
const ticket = tickets.find((t) => t.id === ticketId);
if (!ticket) {
throw new Error(
`No ticket with ID ${ticketId}. Use ticket_search to find the right ID.`,
);
}
return ticket;
}
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
import {
findTicket,
loadTickets,
saveTickets,
type Ticket,
} from "./store.js";
const statusSchema = z.enum(["open", "pending", "closed"]);
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
function summary(ticket: Ticket): string {
return `${ticket.id} [${ticket.status}] ${ticket.subject} (customer: ${ticket.customer})`;
}
function matches(
ticket: Ticket,
words: string[],
status: Ticket["status"] | undefined,
): boolean {
if (status !== undefined && ticket.status !== status) return false;
const haystack = [
ticket.subject,
ticket.customer,
...ticket.comments.map((c) => c.text),
]
.join(" ")
.toLowerCase();
return words.every((word) => haystack.includes(word));
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "tickets", version: "1.0.0" });
server.registerTool(
"ticket_search",
{
description: [
"Search support tickets by words in the subject, customer name or comments.",
"Use this when the user asks to find, look up or list tickets, or asks what tickets a customer has.",
"It returns one line per ticket with its ID, status and subject. To read a ticket in full, call ticket_get with its ID.",
].join(" "),
inputSchema: z.object({
query: z
.string()
.describe(
"Words to find in the ticket subject, customer name or comments. Case-insensitive. Pass an empty string to list tickets without a text filter.",
),
status: statusSchema
.optional()
.describe(
"Only return tickets with this status. Leave out to include all statuses.",
),
limit: z
.number()
.int()
.min(1)
.max(50)
.default(10)
.describe("Maximum number of tickets to return, from 1 to 50."),
}),
},
async ({ query, status, limit }) => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const found = loadTickets()
.filter((t) => matches(t, words, status))
.slice(0, limit);
if (found.length === 0) {
return text("No tickets match. Try fewer words or remove the status filter.");
}
return text(found.map(summary).join("\n"));
},
);
server.registerTool(
"ticket_get",
{
description: [
"Get one support ticket in full: its status, customer and every comment in order.",
"Use this when the user asks for the details of a ticket or quotes a ticket ID such as T-1001.",
"It returns an error if no ticket has that ID, so search first if you're not sure of the ID.",
].join(" "),
inputSchema: z.object({
ticket_id: z
.string()
.describe("The ticket ID, such as T-1001. Get IDs from ticket_search."),
}),
},
async ({ ticket_id }) => {
const ticket = findTicket(loadTickets(), ticket_id);
const lines = [summary(ticket), ""];
lines.push(...ticket.comments.map((c) => `${c.author}: ${c.text}`));
return text(lines.join("\n"));
},
);
server.registerTool(
"ticket_add_comment",
{
description: [
"Add a comment to an existing support ticket.",
"Use this when the user wants to reply to a customer, leave an internal note or record progress on a ticket.",
"It doesn't change the ticket's status. To close a ticket, use ticket_close instead.",
].join(" "),
inputSchema: z.object({
ticket_id: z
.string()
.describe("The ticket ID, such as T-1001. Get IDs from ticket_search."),
author: z
.string()
.min(1)
.describe("Name of the person writing the comment, such as 'Dana (support)'."),
text: z
.string()
.min(1)
.describe("The comment text, written as it should appear on the ticket."),
}),
},
async ({ ticket_id, author, text: commentText }) => {
const tickets = loadTickets();
const ticket = findTicket(tickets, ticket_id);
ticket.comments.push({ author, text: commentText });
saveTickets(tickets);
console.error(`Added comment to ${ticket_id}`);
return text(
`Added comment to ${ticket_id}. The ticket now has ${ticket.comments.length} comments.`,
);
},
);
server.registerTool(
"ticket_close",
{
description: [
"Close a support ticket once the customer's issue is resolved.",
"Use this only when the user says the issue is fixed or asks to close the ticket.",
"It sets the status to closed and adds the resolution as a comment. This server has no tool to reopen a ticket.",
].join(" "),
inputSchema: z.object({
ticket_id: z
.string()
.describe("The ticket ID, such as T-1001. Get IDs from ticket_search."),
resolution: z
.string()
.min(1)
.describe(
"One sentence saying how the issue was resolved. It's saved as the last comment on the ticket.",
),
}),
},
async ({ ticket_id, resolution }) => {
const tickets = loadTickets();
const ticket = findTicket(tickets, ticket_id);
ticket.status = "closed";
ticket.comments.push({ author: "support", text: `Resolved: ${resolution}` });
saveTickets(tickets);
console.error(`Closed ${ticket_id}`);
return text(`Closed ${ticket_id}.`);
},
);
return server;
}
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Tickets server error:", error),
});
These are the four tools it lists:
| Name | What the description covers | Parameters |
|---|---|---|
ticket_search | What it searches, when to use it, that it returns one line per ticket, and that ticket_get reads a ticket in full | query, status (optional enum), limit (1 to 50, default 10) |
ticket_get | Full details and every comment, when to use it, and that it errors on an unknown ID | ticket_id |
ticket_add_comment | When to add a comment, and that it doesn't change the status, with ticket_close named as the alternative | ticket_id, author, text |
ticket_close | When to close, what it sets, and that the server can't reopen a ticket | ticket_id, resolution |
Three changes do most of the work:
- Names carry the object and the verb. Every tool starts with
ticket_, and each action has one verb (search,get,add_comment,close). - Descriptions say when not to use the tool.
ticket_add_commentpoints toticket_closefor closing, so the model doesn't try to close tickets with the wrong tool. - Parameters have descriptions, defaults and limits.
limithas a range and a default, andstatusis an enum, so an invented status fails validation instead of reaching your code.
The Python server also raises ToolError for an unknown ticket ID, not a plain ValueError. That detail matters, and tool errors covers it.
Check the listing with MCP Inspector
MCP Inspector shows the same list your AI app sees. From your project folder, list the tools in both protocol eras. Modern is the 2026-07-28 spec. Legacy is the 2025-era handshake, which older clients still use.
npx @modelcontextprotocol/inspector@2.11.0 --cli uv run good_server.py --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector@2.11.0 --cli uv run good_server.py --protocol-era legacy --method tools/list
For TypeScript, replace uv run good_server.py with node build/good.js after building. Each run should list ticket_search, ticket_get, ticket_add_comment and ticket_close, with the descriptions from the files above. If you see search and get, you're running bad_server.py.
We ran both servers in both eras, and called each good tool to check its output. An unknown ticket ID returns isError: true with a message that names the ID.
Common mistakes
Using search or get as the whole name. These names can collide with other servers' tools and say nothing about the object. Add the service or resource prefix.
Writing a one-line description. Search. gives the model no way to tell it apart from a similar tool. Add the four parts from the earlier section, in one short paragraph.
Leaving out what the tool doesn't do. When a request is close to another tool's job, the model needs the boundary. Name the other tool in the description.
Raising a plain Python exception for an expected failure. A ValueError reaches the model as Error executing tool ticket_get, without your message. Raise ToolError from mcp.server.mcpserver.exceptions so the model can read the message and correct its next call. The tool errors guide covers this in full.
Too many similar tools. Overlapping tools make choosing harder for the model, even with good names. Anthropic's post suggests fewer, distinct tools. The how many tools guide covers the limits.
FAQ
How long should an MCP tool name be?
The spec says between 1 and 128 characters, using only letters, digits, underscores, hyphens and dots. Short, plain names are easier to scan in a long tool list. If you plan to submit to the Anthropic Directory, keep names to 64 characters or fewer.
Should MCP tool names use snake_case or camelCase?
The spec allows either and gives examples of both. Pick one convention and use it across the whole server. The examples in this guide use snake_case.
Can two MCP servers have a tool with the same name?
Yes. Tool names only need to be unique within one server. A client that combines several servers may prefix the names with a server identifier to tell them apart.
Does a long tool description cost anything?
Yes. The model receives every tool's name, description and schema in its context, so a long description uses space on every request. Keep each one to what the model needs to pick the tool correctly.
What should I change first if the model keeps picking the wrong tool?
Start with the descriptions. Add when to use each tool and which sibling to use instead, then test with realistic requests. Change the names only if two tools still look alike after that.
- Build an MCP serverSet up a project, write tools and test them with MCP Inspector.
- Tool input schemasParameter types, enums, defaults and validation for tool arguments.
- Tool annotationsHints such as read-only and destructive, and what they do not guarantee.
- Tool errorsErrors the model can recover from, and how to raise them in Python and TypeScript.
- How many tools is too manyWhen to split a server, and how many tools a model can handle well.