MCP Servers
Tool Definition Best Practices
The MCP-specific mechanics of tool definitions - schema typing, the outputSchema contract, response leanness, and the isError pattern
Last verified 2026-09-24
Summary
Naming and description writing are covered in full on Naming and Descriptions; this page covers what is specific to : typed schemas, the outputSchema/structuredContent contract, response leanness, and the isError pattern.
- Schema: describe every field with
.describe(); list required parameters outputSchemarequires returningstructuredContentalongside or instead ofcontent- Response leanness: return only the fields an agent acts on
isError: truefor domain errors; thrown exceptions for protocol errors- Tool granularity: one tool, one coherent user intention
Tool definitions are the primary interface between your server and the agent using it. An agent cannot ask you to clarify what a tool does - it reads the name and description, infers intent, decides whether to call it, and constructs the arguments. Every ambiguity in your definition becomes a failure mode at runtime.
Naming and Descriptions
Tool naming (snake_case verb_noun as an Agent Surface convention, not an MCP requirement - the MCP 2026-07-28 tool-names section lists getUser as a valid name) and description writing (the four-questions framework, mutation/pagination/ordering patterns) are covered on Naming and Descriptions. Namespacing tools when multiple servers are connected is covered there too.
Schema Design with Zod
Every field in your input schema must have a .describe() annotation. Without descriptions, the agent has only the field name to work from when constructing arguments.
import { z } from "zod";
const CreateInvoiceSchema = z.object({
// UUID format hint prevents the agent from passing a numeric ID
customer_id: z
.string()
.uuid()
.describe(
"The UUID of the customer to invoice. Get this from billing_list_customers " +
"or billing_get_customer - do not pass a customer name or email.",
),
// Integer type with domain context
amount_cents: z
.number()
.int()
.positive()
.max(100_000_000) // $1M ceiling
.describe(
"Invoice amount in cents (100 = $1.00). Use integer cents to avoid " +
"floating point rounding errors.",
),
// Enum communicates the exact valid set
currency: z
.enum(["usd", "eur", "gbp", "cad", "aud"])
.describe("ISO 4217 currency code. Defaults to the customer's billing currency if omitted."),
// Regex makes the format machine-verifiable
due_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD format")
.describe(
"Payment due date in YYYY-MM-DD format (e.g. '2025-02-28'). " +
"Must be today or a future date.",
),
// Optional fields declare their default
line_items: z
.array(
z.object({
description: z.string().max(200).describe("Item description shown on the invoice"),
quantity: z.number().int().positive().describe("Number of units"),
unit_price_cents: z.number().int().positive().describe("Price per unit in cents"),
}),
)
.optional()
.describe(
"Line items to include on the invoice. If omitted, a single line item is created " +
"using the amount_cents and any description provided at the top level.",
),
});Schema design rules:
- Use
.uuid()for ID fields - it rejects numeric IDs that look like strings - Use
.enum()overz.string()for fields with a fixed valid set - Use
.regex()to encode format constraints that cannot be expressed with types alone - Use
.int()for counts and monetary amounts - prevents2.5 items - Use
.max()on strings and arrays - prevents accidental giant payloads - Use
.optional()with.describe()that states the default behavior
The outputSchema Requirement
When you declare outputSchema on a tool, the MCP protocol requires your handler to return a structuredContent field alongside (or instead of) the standard content array. Declaring an output schema without returning structured content is a protocol violation.
const InvoiceOutputSchema = z.object({
invoice_id: z.string().uuid(),
status: z.enum(["draft", "open", "paid", "void"]),
amount_cents: z.number().int(),
payment_url: z.string().url().optional(),
});
server.tool(
"billing_create_invoice",
"Creates a new invoice...",
CreateInvoiceSchema,
{ outputSchema: InvoiceOutputSchema },
async (params) => {
const invoice = await createInvoice(params);
const structured = {
invoice_id: invoice.id,
status: invoice.status,
amount_cents: invoice.amountCents,
payment_url: invoice.hostedUrl ?? undefined,
};
return {
// Required when outputSchema is declared
structuredContent: structured,
// Human-readable content is still useful for agents
// that display results to users
content: [
{
type: "text",
text: `Invoice ${invoice.id} created for ${formatCents(invoice.amountCents, invoice.currency)}.`,
},
],
};
},
);Declaring outputSchema enables downstream agents to receive your tool's output as typed
structured data rather than free-form text. Use it for tools whose output will be consumed by
other tools or stored in agent state.
Current 2026-07-28 behavior (SEP-2106): inputSchema and outputSchema accept any JSON Schema
2020-12 keywords, including $ref, so shared definitions no longer have to be inlined, and
structuredContent may be any JSON value rather than only an object. This loosens a constraint
rather than breaking anything - schemas written against the current subset stay valid - so keep
authoring conservative schemas until the clients you care about have caught up, since a client
that cannot resolve $ref will simply fail to validate your tool.
Response Leanness
Return exactly what the agent needs. Avoid returning full database records when the agent only needs an ID and a status field.
// AVOID: returning the entire record
return {
content: [
{
type: "text",
text: JSON.stringify(invoice), // 40+ fields, most irrelevant
},
],
};
// PREFER: returning only the fields agents act on
return {
content: [
{
type: "text",
text: JSON.stringify({
invoice_id: invoice.id,
status: invoice.status,
payment_url: invoice.hostedUrl,
amount_cents: invoice.amountCents,
currency: invoice.currency,
due_date: invoice.dueDate,
}),
},
],
};Each extra field in a response consumes in the agent's . Over many tool calls in a long session, this adds up. A response that is five times larger than necessary is five times more expensive and may cause context truncation in long-running agents.
The isError Pattern
MCP distinguishes between two kinds of errors:
- Protocol errors - the tool call itself failed (invalid parameters, server crashed, timeout). These throw exceptions and result in MCP error responses. The agent typically cannot recover from these.
- Tool execution errors - the tool ran successfully but the operation failed (customer not found, insufficient balance, duplicate invoice). These return normally with
isError: truein the content.
Use isError: true for all domain errors. The agent can read the error message, understand what happened, and decide to try a different approach.
async (params) => {
const customer = await db.customers.findById(params.customer_id);
if (!customer) {
return {
isError: true,
content: [
{
type: "text",
text:
`Customer ${params.customer_id} not found. ` +
`Call billing_list_customers to find the correct customer ID.`,
},
],
};
}
if (customer.creditLimitCents < params.amount_cents) {
return {
isError: true,
content: [
{
type: "text",
text:
`Invoice amount ${formatCents(params.amount_cents)} exceeds customer credit limit ` +
`of ${formatCents(customer.creditLimitCents)}. ` +
`Reduce the amount or call billing_increase_credit_limit first.`,
},
],
};
}
const invoice = await createInvoice(customer, params);
return {
content: [
{
type: "text",
text: JSON.stringify({
invoice_id: invoice.id,
status: invoice.status,
payment_url: invoice.hostedUrl,
}),
},
],
};
};Writing Actionable Error Messages
Every isError: true response should tell the agent what to do next. An error message that only states the problem is half complete.
// INCOMPLETE: states the problem, gives no recovery path
"Customer not found.";
// COMPLETE: states the problem and provides recovery steps
"Customer abc-123 not found. " +
"If you have the customer's email, call billing_list_customers with filter.email to find their ID. " +
"If the customer was recently created, there may be a replication delay - retry in 10 seconds.";
// INCOMPLETE: vague constraint violation
("Invalid amount.");
// COMPLETE: specifies the constraint and the valid range
"amount_cents must be between 100 (minimum $1.00) and 100000000 (maximum $1,000,000.00). " +
`Received: ${params.amount_cents}.`;The pattern is: [what failed] + [why it failed] + [how to fix it]. All three parts are required for an agent to recover without human intervention.
Never throw uncaught exceptions from tool handlers. Uncaught exceptions produce MCP protocol
errors with generic messages. Catch domain errors and return them as isError: true responses
with actionable messages instead.
Tool Granularity
Tools that do too much are ambiguous - the agent cannot tell which variant of the operation will be performed. Tools that do too little require chaining many calls to accomplish a single user intent.
A useful heuristic: each tool should map to a single, coherent user intention.
// TOO BROAD: agent cannot predict the side effects
"Processes the invoice, which may create, send, void, or archive it based on its current state.";
// TOO GRANULAR: three round trips to do one thing
billing_validate_invoice_params();
billing_reserve_invoice_number();
billing_commit_invoice();
// APPROPRIATE: one call, one outcome, predictable side effects
billing_create_invoice(); // creates draft
billing_send_invoice(); // transitions to open, sends email
billing_void_invoice(); // transitions to void, notifies customerWhen in doubt, model your tools against the operations in your domain . One HTTP endpoint, one tool - with the exception of CRUD list/get operations that are often worth splitting.
Related
- Naming and Descriptions - verb-first naming conventions and descriptions written as onboarding prompts
- Idempotency and Safety - , upsert patterns, and design
- Token Budget - sizing tool definitions and responses for context cost