---
title: "Tool Definition Best Practices"
description: "The MCP-specific mechanics of tool definitions - schema typing, the outputSchema contract, response leanness, and the isError pattern"
url: "https://agentsurface.dev/docs/mcp-servers/tool-best-practices"
lastVerified: 2026-09-24
lastModified: 2026-09-25T05:46:01.000Z
---



## Summary [#summary]

Naming and description writing are covered in full on [Naming and Descriptions](/docs/tool-design/naming-and-descriptions); this page covers what is specific to MCP: typed schemas, the `outputSchema`/`structuredContent` contract, response leanness, and the `isError` pattern.

* Schema: describe every field with `.describe()`; list required parameters
* `outputSchema` requires returning `structuredContent` alongside or instead of `content`
* Response leanness: return only the fields an agent acts on
* `isError: true` for 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 [#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](/docs/tool-design/naming-and-descriptions). Namespacing tools when multiple servers are connected is covered there too.

## Schema Design with Zod [#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.

```typescript
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()` over `z.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 - prevents `2.5 items`
* Use `.max()` on strings and arrays - prevents accidental giant payloads
* Use `.optional()` with `.describe()` that states the default behavior

## The outputSchema Requirement [#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.

```typescript
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)}.`,
        },
      ],
    };
  },
);
```

<Callout type="info">
  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.
</Callout>

<Callout type="info">
  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.
</Callout>

## Response Leanness [#response-leanness]

Return exactly what the agent needs. Avoid returning full database records when the agent only needs an ID and a status field.

```typescript
// 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 tokens in the agent's context window. 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 [#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: true` in 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.

```typescript
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 [#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.

```typescript
// 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.

<Callout type="warn">
  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.
</Callout>

## Tool Granularity [#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.

```typescript
// 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 customer
```

When in doubt, model your tools against the operations in your domain API. One HTTP endpoint, one tool - with the exception of CRUD list/get operations that are often worth splitting.

## Related [#related]

* [Naming and Descriptions](/docs/tool-design/naming-and-descriptions) - verb-first naming conventions and descriptions written as onboarding prompts
* [Idempotency and Safety](/docs/tool-design/idempotency-and-safety) - idempotency keys, upsert patterns, and dry-run design
* [Token Budget](/docs/tool-design/token-budget) - sizing tool definitions and responses for context cost
