Designing Claude Connector Tools: Schemas, Descriptions, and Patterns for Reliable Tool Calls
Designing tools that Claude calls correctly.
TL;DR: Claude picks which tool to call based on the tool’s name, description, and schema. Vague descriptions cause wrong or missed calls. Write descriptions that say exactly what the tool does and returns, use specific Zod schemas with .describe() on every field, keep tools small and focused, and test tool selection with sunpeak simulations.
Your Claude Connector is only as good as its tools. You can build the cleanest MCP server with perfect OAuth and a polished UI, but if Claude calls the wrong tool, passes bad arguments, or skips your tool entirely, none of that matters. Tool design is the part that determines whether your connector actually works in conversation.
This post covers the patterns that make Claude call your tools correctly, based on what we have seen building and testing connectors with sunpeak.
How Claude Selects Tools
When a user sends a message, Claude receives the full list of available tools from every connected connector. For each tool, Claude sees three things:
- The tool name (e.g.,
search-tickets) - The description (e.g., “Search open support tickets by keyword, status, or assignee”)
- The input schema (the JSON Schema generated from your Zod types)
Claude reads these, decides whether any tool matches the user’s intent, picks one, and generates arguments that conform to the schema. If the description is ambiguous, Claude might pick the wrong tool, pass the wrong arguments, or decide not to call any tool at all.
You control all three. That is the good news. The bad news is that most tool design problems come from descriptions that are either too vague or too complex.
Writing Tool Descriptions That Work
The description is the most important field in your tool config. It is how Claude knows when and why to call your tool.
A bad description:
export const tool: AppToolConfig = {
resource: 'ticket',
title: 'Ticket Tool',
description: 'Interact with tickets',
annotations: { readOnlyHint: true },
};
“Interact with tickets” tells Claude nothing. Search tickets? Create tickets? Delete them? Claude has to guess, and it often guesses wrong.
A good description:
export const tool: AppToolConfig = {
resource: 'ticket',
title: 'Search Tickets',
description:
'Search open support tickets by keyword, status, or assignee. ' +
'Returns ticket ID, title, status, priority, and assignee for each match. ' +
'Use this when the user wants to find or filter tickets.',
annotations: { readOnlyHint: true },
};
This description does three things:
- Says what the tool does: search tickets by keyword, status, or assignee
- Says what it returns: ticket ID, title, status, priority, and assignee
- Says when to use it: when the user wants to find or filter tickets
Including the return shape matters because Claude needs to know what information it will get back. If Claude does not know that your tool returns assignee information, it might not call it when a user asks “who is working on the billing bug?”
Description guidelines
- Lead with the verb: “Search,” “Create,” “Update,” “Delete,” “List,” “Get”
- Name the data: “support tickets,” not “items” or “records”
- Include filterable fields: “by keyword, status, or assignee”
- Mention the return shape: “Returns ticket ID, title, status, priority”
- Add a “use this when” clause if the trigger is not obvious
- Keep it under 200 words. Claude reads every tool description on every message, so brevity matters at scale.
Designing Schemas with Zod
Your schema defines what arguments Claude can pass. In a sunpeak project, schemas are Zod objects that automatically convert to JSON Schema for the MCP protocol.
import { z } from 'zod';
export const schema = {
query: z.string().describe('Search keyword to match against ticket title and description'),
status: z.enum(['open', 'in_progress', 'resolved']).optional()
.describe('Filter by ticket status. Omit to search all statuses.'),
assignee: z.string().optional()
.describe('Filter by assignee name. Partial match.'),
limit: z.number().min(1).max(50).optional()
.describe('Maximum number of results to return. Defaults to 10.'),
};
Every field has .describe(). This is not optional in practice, even though Zod does not require it. The descriptions become part of the JSON Schema that Claude reads, and they are how Claude decides what value to pass for each field.
Schema design rules
Use .describe() on every field. Claude uses these descriptions to decide what values to pass. A field named q with no description forces Claude to guess. A field named query described as “Search keyword to match against ticket title and description” tells Claude exactly what to pass.
Use z.enum() for fixed options. If a field only accepts specific values (status, priority, sort order), use an enum. Claude can read the allowed values from the schema and will always pass a valid one.
status: z.enum(['open', 'in_progress', 'resolved']).optional()
.describe('Filter by ticket status'),
Use .optional() for fields Claude should skip when the user does not mention them. If every field is required, Claude has to invent values for fields the user did not specify. Optional fields let Claude pass only what the user asked for.
Use .default() sparingly. Defaults are not visible in the JSON Schema that Claude reads. If you need Claude to know about a default, put it in the description: “Defaults to 10.”
Avoid z.any() and z.unknown(). Claude cannot reason about untyped fields. If a field could be multiple types, use z.union() with explicit types.
Add constraints where they exist. .min(), .max(), .regex(), .url(), .email() all show up in the JSON Schema and help Claude pass valid values.
Tool Granularity: Small Tools Beat Big Ones
A common mistake is building one large tool that handles multiple operations:
// Don't do this
export const tool: AppToolConfig = {
resource: 'ticket',
title: 'Ticket Operations',
description: 'Search, create, update, or delete support tickets',
annotations: { destructiveHint: true },
};
export const schema = {
operation: z.enum(['search', 'create', 'update', 'delete'])
.describe('The operation to perform'),
// ... many conditional fields
};
This causes problems. Claude has to parse a complex description to figure out which “mode” to use. The schema includes fields that only apply to certain operations, so Claude might pass create-only fields when searching. And the annotations are wrong because readOnlyHint and destructiveHint cannot both be true, so you end up marking a search operation as destructive.
Split it into focused tools:
// src/tools/search-tickets.ts
export const tool: AppToolConfig = {
resource: 'ticket-list',
title: 'Search Tickets',
description: 'Search support tickets by keyword, status, or assignee.',
annotations: { readOnlyHint: true },
};
// src/tools/create-ticket.ts
export const tool: AppToolConfig = {
resource: 'ticket',
title: 'Create Ticket',
description: 'Create a new support ticket with a title, description, and priority.',
annotations: { destructiveHint: true },
};
// src/tools/update-ticket.ts
export const tool: AppToolConfig = {
title: 'Update Ticket',
description: 'Update the status, priority, or assignee of an existing ticket.',
annotations: { destructiveHint: true, idempotentHint: true },
};
Each tool has a clear description, correct annotations, and a schema with only the fields that apply. Claude can match “find my open tickets” to search-tickets and “assign TICK-42 to Sarah” to update-ticket without any ambiguity.
How many tools is too many?
There is no hard limit, but keep in mind that Claude reads every tool description on every message. A connector with 50 tools adds latency and increases the chance of Claude picking the wrong one. If you have more than 10-15 tools, consider whether some can be combined or whether you are exposing internal operations that users will never ask for directly.
The Connectors Directory submission requirements include a 25,000-token limit on tool results, but there is no published tool count limit. Focus on exposing the operations your users actually need.
Return Types: structuredContent vs. Text
Your tool handler returns data to Claude. How you format that return determines what happens next.
structuredContent for UI tools
If your tool has a paired MCP App resource (a React component), return structuredContent. The data flows to your component via useToolData():
export default async function (args: Args, _extra: ToolHandlerExtra) {
const ticket = await fetchTicket(args.ticketId);
return {
structuredContent: {
id: ticket.id,
title: ticket.title,
status: ticket.status,
priority: ticket.priority,
assignee: ticket.assignee,
created: ticket.createdAt,
description: ticket.description,
},
};
}
Claude renders your React component with this data inside the chat. The user sees a visual card, chart, or form instead of plain text.
Text content for data-only tools
If your tool does not have a UI component, return text that Claude can reason over:
export default async function (args: Args, _extra: ToolHandlerExtra) {
const count = await getOpenTicketCount(args.teamId);
return `Team ${args.teamId} has ${count} open tickets.`;
}
Claude will weave this into its response. A plain string return automatically gets wrapped as text content by the MCP SDK.
When to use which
Use structuredContent when the result benefits from visual presentation: data tables, status cards, charts, forms, media. Use text content when the result is a fact or summary that Claude should incorporate into a natural language answer.
You can also combine them. Return structuredContent for the UI and include a content array with a text summary for Claude to use alongside the rendered component:
return {
structuredContent: { /* data for your component */ },
content: [{ type: 'text', text: 'Found 3 open high-priority tickets.' }],
};
Review and Confirmation Patterns
For tools that modify data, consider splitting the flow into a review step and an execution step. This gives users a chance to confirm before changes happen.
The pattern uses two tools: one that shows what will happen (UI tool with readOnlyHint) and one that does it (backend tool with destructiveHint):
// src/tools/preview-ticket-update.ts
export const tool: AppToolConfig = {
resource: 'ticket-update-preview',
title: 'Preview Ticket Update',
description: 'Show a preview of changes to a ticket before applying them.',
annotations: { readOnlyHint: true },
};
// src/tools/apply-ticket-update.ts
export const tool: AppToolConfig = {
title: 'Apply Ticket Update',
description: 'Apply a previously previewed update to a ticket.',
annotations: { destructiveHint: true, idempotentHint: true },
_meta: { ui: { visibility: ['app'] } },
};
The _meta.ui.visibility setting on the execution tool means only the app (your resource component) can call it, not Claude directly. The user clicks a “Confirm” button in your preview UI, which triggers the apply tool. This prevents Claude from skipping the review step.
Annotations Are Not Optional
Every tool needs annotations if you plan to submit to the Connectors Directory. Missing annotations cause 30% of rejections. But even for custom connectors, annotations matter because they tell Claude how to handle the tool:
readOnlyHint: true: Claude can call this tool freely. No side effects.destructiveHint: true: Claude may ask the user for confirmation before calling.idempotentHint: true: Safe to retry. Calling twice with the same args has no additional effect.openWorldHint: true: The tool calls external APIs or services beyond your server.
If you mark a destructive tool as read-only, Claude might call it without confirmation and your users will have a bad time. If you mark a read-only tool as destructive, Claude adds friction by asking for confirmation on every search.
Common Mistakes
Overlapping descriptions. If two tools have similar descriptions, Claude will pick one arbitrarily. “Get ticket details” and “Show ticket information” sound the same to Claude. Make each description distinct: “Get ticket by ID, returns all fields” vs. “Search tickets by keyword, returns summary list.”
Missing .describe() on schema fields. Without descriptions, Claude sees parameter names and types but no context. A string field named id could be a ticket ID, a user ID, or a conversation ID. The description resolves the ambiguity.
Required fields for optional filters. If your search tool requires a status field, Claude has to pass one even when the user just says “show me my tickets.” Make filter fields optional so Claude can omit them.
Returning too much data. Tool results are capped at 25,000 tokens. If your tool returns a list, paginate it. Include a limit parameter in your schema and default to a reasonable number (10-20 items). Return a total count so Claude can tell the user there are more results.
Tool names with special characters. Stick to lowercase alphanumeric characters and hyphens. Tool names become file names in a sunpeak project (src/tools/search-tickets.ts), and they must be under 64 characters for the Connectors Directory.
Testing Tool Design with Simulations
You can test whether your tools produce the right output for specific inputs using sunpeak simulations. A simulation defines a tool call with specific arguments and the expected result:
{
"tool": "search-tickets",
"userMessage": "Find all high priority tickets assigned to Sarah",
"toolInput": {
"query": "",
"status": "open",
"assignee": "Sarah",
"limit": 10
},
"toolResult": {
"structuredContent": {
"tickets": [
{
"id": "TICK-1234",
"title": "Search results not loading on mobile",
"status": "open",
"priority": "high",
"assignee": "Sarah Chen"
}
],
"total": 1
}
}
}
Run sunpeak dev, and this simulation renders in the local Claude inspector exactly as it would in a real Claude session. You can verify that your resource component handles the data correctly without a Claude account.
For automated testing, write Playwright tests against your simulations:
import { test, expect } from '@playwright/test';
import { createInspectorUrl } from 'sunpeak/chatgpt';
test('search-tickets renders results', async ({ page }) => {
await page.goto(
createInspectorUrl({ simulation: 'search-tickets', host: 'claude' })
);
const iframe = page.frameLocator('iframe');
await expect(iframe.locator('text=TICK-1234')).toBeVisible();
await expect(iframe.locator('text=Sarah Chen')).toBeVisible();
});
Run this in CI to catch regressions before they reach production.
Putting It Together
Good tool design for Claude Connectors comes down to a few principles: write descriptions that say what the tool does, returns, and when to use it; define schemas with described, typed, and constrained fields; keep tools small and focused; use the right return type for your use case; and annotate everything.
If you are building a connector and want to iterate on tool design quickly without reloading Claude manually, sunpeak gives you a local Claude inspector with automatic rebuilds. Change a description, save, and see the result immediately.
Get Started
pnpm add -g sunpeak && sunpeak new
Further Reading
- Claude Connectors tutorial - build and deploy a connector from scratch
- Claude Connector Directory submission - requirements, annotations, and how to pass review
- MCP App tutorial - full walkthrough of resources, tools, and simulations
- What are Claude Connectors - data access, auth, and troubleshooting
- Claude Connector authentication - OAuth flow and callback URLs
- MCP concepts explained - tools, resources, and how MCP Apps use them
- Claude Connector Framework - sunpeak overview
- Tool file reference - full API docs for tool config, schema, and handler
- MCP tools overview - protocol-level tool concepts
Frequently Asked Questions
How does Claude decide which connector tool to call?
When you send a message, Claude sees every tool name, description, and input schema from your connected connectors. It matches your request against tool descriptions to decide which tool to call and what arguments to pass. Clear, specific descriptions are the single biggest factor in whether Claude picks the right tool.
What makes a good Claude Connector tool description?
A good tool description says exactly what the tool does, what data it returns, and when Claude should call it. Avoid vague descriptions like "interact with the service." Instead write "Search open support tickets by keyword, status, or assignee. Returns ticket ID, title, status, priority, and assignee for each match." Include the return shape so Claude knows what to expect.
Should I use one big tool or many small tools in my Claude Connector?
Prefer small, focused tools over one large multi-purpose tool. Claude selects tools based on descriptions, and a tool that does five things needs a description complex enough to cover all five. Small tools with clear names and descriptions are easier for Claude to match to user intent. Group related read operations (search, get-by-id, list) as separate tools.
What Zod types should I use for Claude Connector tool schemas?
Use z.string() with .describe() for text inputs, z.enum() for fixed option sets (status filters, sort orders), z.number() for numeric inputs, z.boolean() for flags, and z.array() for lists. Always add .describe() to every field. Use .optional() for fields Claude should only pass when the user specifies them. Avoid z.any() or z.unknown() since Claude cannot reason about untyped fields.
What is the difference between structuredContent and text content in Claude Connector tools?
structuredContent is typed data that gets passed to your MCP App resource component via useToolData(). Use it when your tool has a UI component that renders the result. Text content (content with type text) goes directly into Claude conversation as text that Claude can reason over. Use text content for tools that return information Claude should summarize or discuss.
Do all Claude Connector tools need annotations?
Yes, if you plan to submit to the Connectors Directory. Every tool must include either readOnlyHint: true (for read operations) or destructiveHint: true (for write operations). Missing annotations cause 30% of Directory rejections. Even for custom connectors, annotations help Claude make safer decisions about when to call tools without asking for confirmation.
How do I test whether Claude calls my connector tools correctly?
Use sunpeak simulations to define expected tool inputs and outputs, then run automated tests with Playwright. Simulations let you verify that your tool renders the right UI for specific inputs without needing a Claude account. For end-to-end testing against real Claude, connect via ngrok and test each tool in a conversation.
Can multiple Claude Connector tools share the same UI component?
Yes. Multiple tool files can reference the same resource name in their tool config. This lets different tools (like search-tickets and get-ticket) provide different data to a shared UI component. The resource component receives whatever structuredContent the active tool returns via useToolData().