MCP Tool Design
A well-designed tool is invoked correctly by the agent on the first try. A bad one causes loops, wrong-tool selection, or hallucinated arguments.
When to Use
- Adding a new tool to an MCP server
- Debugging "the model never picks this tool" or "the model picks the wrong tool"
- Reviewing a PR that adds tools
- Cleaning up a server with 30+ tools
The Three Levers
Agents pick tools based on name, description, and parameter schema — in that order of signal strength. Every design choice should strengthen at least one.
Naming Rules
| Good | Bad | Why |
|---|---|---|
search_issues |
issueSearcher |
snake_case, verb-led |
get_user_by_email |
user_lookup |
specific over vague |
create_pr_comment |
comment |
namespaced by object |
list_repos |
repos |
action is explicit |
Rule: <verb>_<object>[_<qualifier>]. If two tools could answer the same query, one has the wrong name.
Description Rules
Descriptions are what the model reads most carefully. Budget ~1-3 sentences:
<Verb-led action>. <When to use it / when NOT to use it>. <Any gotchas>.
Example — weak vs strong
Weak:
Creates an issue.
Strong:
Create a GitHub issue in the specified repo. Use this for new bug reports or feature requests. Do NOT use to comment on an existing issue — use
create_issue_commentfor that. Title is required; body supports markdown.
The "when NOT to use" line is the highest-leverage sentence you can write — it routes disambiguation without the agent needing to enumerate all tools.
Parameter Schema Rules
- Describe every field —
.describe()in Zod,Field(description=...)in Pydantic. Undescribed fields get hallucinated values - Use enums for closed sets —
priority: z.enum(["low", "med", "high"])beatspriority: z.string() - Default the optional — if 80% of calls use
limit=50, set the default; don't make the agent guess - Prefer primitives at the top level — nested objects increase hallucination rates
- Avoid freeform "options" bags — split into discrete flags
Example — good schema
server.tool(
"search_issues",
"Search GitHub issues across a repo. Returns up to 50 issues matching the query. " +
"Use for finding issues by keyword or label. For a specific issue by number, use `get_issue` instead.",
{
repo: z.string().describe("owner/name format, e.g. 'anthropic/claude-code'"),
query: z.string().describe("Full-text search query, GitHub search syntax supported"),
state: z.enum(["open", "closed", "all"]).default("open"),
labels: z.array(z.string()).optional().describe("Filter by label names (AND semantics)"),
limit: z.number().int().min(1).max(100).default(25),
},
async (args) => { /* ... */ },
);
Return Shape Rules
The model sees the tool result as text. Structure matters:
- Lead with a summary line — "Found 3 open issues matching 'auth bug':"
- Include IDs so the model can chain calls
- Truncate aggressively — don't return 200 rows when 10 will do; include
"... 190 more, use offset=10"hint - Never return raw HTML — strip or convert to markdown
return {
content: [{
type: "text",
text: [
`Found ${results.length} issues matching "${query}":`,
...results.slice(0, 10).map(r => `- #${r.number} ${r.title} (${r.state})`),
results.length > 10 ? `... ${results.length - 10} more. Narrow query or paginate with offset.` : "",
].filter(Boolean).join("\n"),
}],
};
Surface Size
Tool selection accuracy degrades past ~15 tools per connected server set. If you have 40 CRUD operations, collapse them:
- Before:
create_issue, update_issue, delete_issue, close_issue, reopen_issue, assign_issue, ...(12 tools) - After:
issue_action(action: "create"|"update"|"close"|"reopen", ...)(1 tool with discriminated schema)
Only collapse if the sub-actions share 80%+ of their schema. Otherwise the union becomes a mess.
Anti-Patterns
- The God Tool —
execute(query: string)that dispatches everything. Model can't pick wisely; hallucinates queries - Duplicate tools —
search,find,lookup,queryall doing similar things - Hidden required args —
options: objectwhere some keys are required; agent skips them - Silent truncation — returning 10 of 500 results without saying so; agent assumes it got everything
- Tool-name collisions across servers — if user has 3 servers each with a
searchtool, the model gets confused. Prefix:linear_search,github_search
Validation Checklist
Before shipping a tool:
- Name is verb-led and unambiguous
- Description has ≥1 "when NOT to use" sentence if there's a nearby tool
- Every parameter has
.describe() - Closed-set fields use enums
- Defaults cover the common case
- Return includes IDs for chaining
- Return summarizes + truncates large lists
- Tested with MCP Inspector that the model can invoke it zero-shot
Best Practices
- Read your descriptions aloud — if you stumble, the model will too
- Pilot with one real conversation before adding 10 tools at once
- When a user reports "the agent won't use my tool", check description first, schema second, name third
- Log unresolved tool calls (agent tried to call a non-existent tool) — it reveals the tool it WANTED