Designing Claude Connector Tools: Schemas, Descriptions, and Patterns for Reliable Tool Calls (May 2026)

Designing tools that Claude calls correctly.
TL;DR: Claude chooses connector tools from the tool metadata you publish: name, title, description, schema, and annotations. Design each tool around one user intent, describe every input field, return compact data, and test the same metadata locally and against a deployed remote MCP server. In May 2026, you also need to account for remote connector constraints: public HTTPS reachability, OAuth where needed, safety annotations, and host-specific behavior.
Your Claude Connector is only as useful as the tools it exposes. A clean OAuth flow and a polished UI do not help if Claude picks the wrong tool, invents arguments, or avoids the connector because the description is unclear.
This post refreshes the original tool design guide with the current remote MCP connector model, the MCP Apps SDK direction, and the latest sunpeak testing flow. It is still about the same subject: designing connector tools that Claude can call reliably.
What Changed Since March
Claude custom connectors now run through remote MCP in more places. The current Claude Help Center page says custom connectors using remote MCP are available on Claude, Cowork, and Claude Desktop for Free, Pro, Max, Team, and Enterprise users, with Free users limited to one custom connector. It also says the feature is still beta, so build with explicit safety and test against the real host before you depend on a workflow.
Two details matter for tool design:
- Claude connects to remote MCP servers from Anthropic cloud infrastructure, so your server needs to be reachable over the public internet unless you allowlist the current Anthropic IP ranges.
- Users enable connectors per conversation, which means Claude may see only a subset of your server’s tools in a given chat.
The Claude Messages API MCP connector also changed since early examples. The current API docs list mcp-client-2025-11-20 as the active beta header and mark the older mcp-client-2025-04-04 version as deprecated. The API supports remote MCP tool calls, OAuth bearer tokens, multiple servers, allowlists, denylists, and per-tool configuration.
On the UI side, the MCP Apps extension documents a stable 2026-01-26 specification. The basic flow is tool metadata points to a UI resource, the model calls the tool, the host fetches the resource, and the host renders it in a sandboxed iframe. That means your tool schema is now part of both model behavior and app rendering behavior.
How Claude Selects Tools
When a user sends a message, Claude evaluates the enabled tools from connected MCP servers. For each tool, it can use:
- The tool name, such as
search-tickets - The human-readable title, such as
Search Tickets - The description
- The JSON Schema generated from your input schema
- Safety annotations such as
readOnlyHintanddestructiveHint
The model uses those fields to answer three questions:
- Does this tool match the user’s request?
- Can I fill the required arguments from the conversation?
- Is the tool safe to call now, or should the user confirm first?
Bad tool metadata turns those questions into guesses. Good tool metadata makes them boring.
Write Descriptions That Match User Intent
The description is the strongest natural-language signal. It should describe the operation, the data, the return shape, and the trigger.
A weak description:
export const tool: AppToolConfig = {
resource: 'ticket',
title: 'Ticket Tool',
description: 'Interact with tickets',
annotations: { readOnlyHint: true },
};
Claude cannot tell whether this searches tickets, updates tickets, assigns tickets, or deletes tickets.
A stronger description:
export const tool: AppToolConfig = {
resource: 'ticket-list',
title: 'Search Tickets',
description:
'Search support tickets by keyword, status, priority, or assignee. ' +
'Returns ticket ID, title, status, priority, assignee, and updated time. ' +
'Use this when the user wants to find, filter, or review tickets.',
annotations: { readOnlyHint: true },
};
That description gives Claude enough to pick the tool for “show Sarah’s high-priority tickets” and enough to know the result includes assignee and priority data.
Use this checklist for descriptions:
- Start with the verb: Search, List, Get, Create, Update, Send, Delete.
- Name the real domain object: support tickets, invoices, calendar events, pull requests.
- Include searchable or editable fields when they affect selection.
- Say what the tool returns in one sentence.
- Add “Use this when…” for tools whose trigger is not obvious.
- Keep similar tools distinct, because overlapping descriptions cause random-looking tool choices.
Design Schemas Claude Can Fill
Your schema tells Claude what arguments it may pass. In a sunpeak project, the schema is usually a Zod object that converts to JSON Schema for the MCP protocol.
import { z } from 'zod';
export const schema = {
query: z
.string()
.optional()
.describe('Search keyword to match against ticket title, description, or comments.'),
status: z
.enum(['open', 'in_progress', 'blocked', 'resolved'])
.optional()
.describe('Filter by ticket status. Omit when the user does not specify a status.'),
priority: z
.enum(['low', 'medium', 'high', 'urgent'])
.optional()
.describe('Filter by ticket priority.'),
assignee: z.string().optional().describe('Filter by assignee name or email. Partial match is allowed.'),
limit: z
.number()
.int()
.min(1)
.max(25)
.optional()
.describe('Maximum number of tickets to return. Defaults to 10.'),
};
Field descriptions matter because the model sees the generated schema, not your handler code. A field named id could mean ticket ID, customer ID, project ID, or external system ID. A field named ticketId with a description is much easier to fill correctly.
Good schema rules are simple:
- Use
.describe()on every field. - Use
z.enum()for fixed values, because the model can see the allowed set. - Use
.optional()for filters the user might not mention. - Use
.min(),.max(),.int(),.url(),.email(), and regex constraints when they match the real API. - Put defaults in the description if Claude needs to know them.
- Avoid
z.any()andz.unknown()unless you are passing opaque data that Claude should not construct.
If a schema requires data the user rarely provides, Claude may invent values. That is usually a schema design bug. Make the field optional, ask a clarifying question, or split the workflow into a search step followed by a get-by-ID step.
Split Read and Write Tools
One large tool with an operation field looks tidy in code but performs worse in conversation:
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.'),
// Conditional fields for every operation...
};
This mixes different intents, different required fields, and different safety levels. A search operation is read-only. A delete operation is destructive. One annotation cannot describe both accurately.
Split tools by user intent:
// src/tools/search-tickets.ts
export const tool: AppToolConfig = {
resource: 'ticket-list',
title: 'Search Tickets',
description: 'Search support tickets by keyword, status, priority, or assignee.',
annotations: { readOnlyHint: true },
};
// src/tools/get-ticket.ts
export const tool: AppToolConfig = {
resource: 'ticket-detail',
title: 'Get Ticket Details',
description: 'Get one support ticket by ticket ID. Returns full ticket details and recent comments.',
annotations: { readOnlyHint: true },
};
// src/tools/update-ticket-status.ts
export const tool: AppToolConfig = {
title: 'Update Ticket Status',
description: 'Update the status of an existing support ticket.',
annotations: { destructiveHint: true, idempotentHint: true },
};
This gives Claude clear choices. It also gives reviewers and users a clearer safety model.
Use Annotations as Safety Metadata
Annotations are more than review paperwork. They tell the host how risky a tool call is.
Use readOnlyHint: true when the tool only reads data.
annotations: { readOnlyHint: true }
Use destructiveHint: true when the tool changes external state. That includes creating, updating, deleting, sending, scheduling, spending money, uploading files, or triggering a side effect.
annotations: { destructiveHint: true }
Use idempotentHint: true when repeating the exact same call is safe. Updating a ticket status to resolved is often idempotent. Sending a message is usually not.
annotations: { destructiveHint: true, idempotentHint: true }
Use openWorldHint: true when the tool talks to systems outside your MCP server, such as a SaaS API, database, email provider, payment processor, or file store.
annotations: { readOnlyHint: true, openWorldHint: true }
If you cannot describe a tool’s safety in one annotation set, the tool is probably doing too much.
Return Data in the Shape the Next Step Needs
Tool return shape affects both Claude’s answer and your UI.
For a data-only tool, return text or compact structured text that Claude can summarize:
export default async function (args: Args) {
const tickets = await searchTickets(args);
return {
content: [
{
type: 'text',
text: tickets
.map((ticket) => `${ticket.id}: ${ticket.title} (${ticket.status}, ${ticket.priority})`)
.join('\n'),
},
],
};
}
For an interactive MCP App resource, return structuredContent for the component:
export default async function (args: Args) {
const result = await searchTickets(args);
return {
structuredContent: {
tickets: result.tickets.map((ticket) => ({
id: ticket.id,
title: ticket.title,
status: ticket.status,
priority: ticket.priority,
assignee: ticket.assignee,
updatedAt: ticket.updatedAt,
})),
total: result.total,
nextCursor: result.nextCursor,
},
};
}
For the best model behavior, return both when the UI needs data and Claude still needs a short summary:
return {
structuredContent: {
tickets,
total,
nextCursor,
},
content: [
{
type: 'text',
text: `Found ${tickets.length} of ${total} matching tickets. Highest priority: ${highestPriority}.`,
},
],
};
The summary should be brief. Do not dump every field twice.
Add Pagination Before You Need It
Remote MCP tool results can become too large fast. Ticket lists, CRM records, documents, and logs need limits from day one.
Add limit and cursor fields to list tools:
export const schema = {
query: z.string().optional().describe('Search keyword.'),
limit: z.number().int().min(1).max(25).optional().describe('Maximum results to return. Defaults to 10.'),
cursor: z.string().optional().describe('Pagination cursor returned by the previous search call.'),
};
Return total, nextCursor, and a compact item shape:
return {
structuredContent: {
tickets: page.items,
total: page.total,
nextCursor: page.nextCursor,
},
content: [{ type: 'text', text: `Returned ${page.items.length} of ${page.total} tickets.` }],
};
Claude can then tell the user there are more results instead of trying to fit the whole external system into one tool response.
Use Review Flows for Writes
For tools that modify data, split the flow into preview and apply steps. The preview tool is read-only and renders an MCP App resource. The apply tool performs the write only after the user confirms in the UI.
// src/tools/preview-ticket-update.ts
export const tool: AppToolConfig = {
resource: 'ticket-update-preview',
title: 'Preview Ticket Update',
description: 'Show a preview of a ticket status or assignee change before applying it.',
annotations: { readOnlyHint: true },
};
// src/tools/apply-ticket-update.ts
export const tool: AppToolConfig = {
title: 'Apply Ticket Update',
description: 'Apply a previously previewed ticket update after user confirmation.',
annotations: { destructiveHint: true, idempotentHint: true },
_meta: { ui: { visibility: ['app'] } },
};
The first tool lets Claude help prepare the change. The second tool is called by your resource after the user confirms. This pattern is easier to test, easier to review, and safer for users than letting the model jump straight to a write call.
Test the Metadata, Not Just the Handler
A handler unit test proves your code runs. It does not prove Claude can choose the tool. Tool design needs metadata tests too.
Start with a unit test that inspects the exported tool config:
import { describe, expect, test } from 'vitest';
import { schema, tool } from '../src/tools/search-tickets';
describe('search-tickets metadata', () => {
test('has clear read-only metadata', () => {
expect(tool.title).toBe('Search Tickets');
expect(tool.description).toContain('Search support tickets');
expect(tool.description).toContain('Returns ticket ID');
expect(tool.annotations).toMatchObject({ readOnlyHint: true });
});
test('describes all input fields', () => {
for (const field of Object.values(schema)) {
expect(field.description).toBeTruthy();
}
});
});
Then test rendering with a sunpeak simulation:
{
"tool": "search-tickets",
"userMessage": "Find high-priority tickets assigned to Sarah",
"toolInput": {
"priority": "high",
"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
}
}
}
With sunpeak, run inspector and e2e tests against that state:
import { expect, test } from 'sunpeak/test';
test('search-tickets renders ticket results', async ({ inspector }) => {
const result = await inspector.renderTool('search-tickets');
const app = result.app();
await expect(app.getByText('TICK-1234')).toBeVisible();
await expect(app.getByText('Sarah Chen')).toBeVisible();
});
For production readiness, add a live smoke test against the remote MCP server you will connect to Claude. sunpeak can scaffold tests for any MCP server:
npx sunpeak test init --server https://example.com/mcp
npx sunpeak test
This generates e2e, visual regression, live host, and eval scaffolding, depending on the test mode you enable. Use it to catch schema drift, missing annotations, rendering regressions, and host-specific behavior before users find them.
Common Tool Design Bugs
Overlapping descriptions cause wrong calls. If get-ticket says “show ticket information” and search-tickets says “find ticket information,” Claude may choose either. Make one “Get one ticket by ticket ID” and the other “Search tickets by keyword, status, priority, or assignee.”
Required filters make Claude invent data. A search tool should not require status, assignee, or priority unless every valid search needs those fields. Most filters should be optional.
Write tools with read-only annotations are unsafe. If a tool sends, creates, updates, deletes, or triggers an external action, mark it destructive.
Huge results make every next step worse. Return compact summaries, use pagination, and add a get-by-ID tool for detail views.
UI-only data leaves Claude blind. If your interactive resource receives structuredContent, include a short content summary when Claude also needs to explain what happened.
Tool names that mirror internal APIs confuse users and models. Prefer search-tickets over ticketQueryV2 and update-ticket-status over mutateTicket.
A Current Checklist
Before you ship or submit a Claude Connector, check each tool:
- The title is human-readable.
- The description states the operation, data, return shape, and trigger.
- Each tool maps to one user intent.
- Read tools and write tools are split.
- Every schema field has a type, description, and real constraints.
- Optional filters are optional.
- List tools include
limitand pagination when results can grow. - The return shape is compact and matches the UI component contract.
- Every tool has accurate safety annotations.
- Write tools have preview or confirmation flows when the action matters.
- Inspector tests cover the main rendered states.
- A deployed remote MCP server has been tested from outside your local network.
Putting It Together
Reliable Claude Connector tools come from clear metadata and narrow contracts. The model needs to know when to call the tool, what arguments to pass, what the tool will return, and whether the action is safe. Your UI needs stable structuredContent. Your users need confirmation before meaningful writes.
sunpeak helps with the parts that are slow to test manually: local replicated host runtimes, simulation fixtures, Playwright tests, visual regression tests, live host checks, and evals for tool calling. Design the tools clearly first, then let tests keep them clear as the connector grows.
Get Started
npx sunpeak newFurther Reading
- What are Claude Connectors - current setup, auth, and troubleshooting
- Claude Connector Directory submission - current review checklist
- Claude Connector data access patterns - text and structured content
- Testing MCP tool annotations - catch unsafe metadata before review
- MCP App testing strategy - what to test first
- Claude Connector framework - sunpeak overview
- MCP testing framework - sunpeak docs
- MCP Apps specification and SDK
- Claude custom connectors with remote MCP
- Claude Messages API MCP connector
Frequently Asked Questions
How does Claude decide which connector tool to call?
Claude sees the tools exposed by enabled connectors, including each tool name, title, description, input schema, and annotations. It matches the user request against that metadata, chooses a tool when one fits, and generates arguments that match the schema. Clear descriptions and described schema fields give Claude the strongest signal.
What makes a good Claude Connector tool description?
A good tool description says what the tool does, what data it reads or changes, what it returns, and when Claude should use it. A search tool should mention searchable fields, result shape, and limits. A write tool should say exactly which side effect it performs and when it should be used.
Should I use one big tool or many small tools in my Claude Connector?
Prefer focused tools that map to one user intent, such as search tickets, get ticket details, create ticket, and update ticket status. Large tools with an operation parameter make schemas harder for Claude to fill and make safety annotations less accurate because read and write behavior share one tool.
What schema fields should every Claude Connector tool include?
Every input field should have a specific type, a plain-language description, and constraints where they exist. Use enums for fixed values, optional fields for filters the user may omit, min and max constraints for limits, URL or email validation where appropriate, and avoid any or unknown fields unless there is no better typed shape.
What is the difference between structuredContent and text content in Claude Connector tools?
structuredContent is typed data for an MCP App resource to render. Text content is natural language or markdown that Claude can read and summarize in the conversation. Interactive tools often return both: structuredContent for the UI and a short text summary for the model.
What annotations do Claude Connector tools need?
Use readOnlyHint for tools that only read data. Use destructiveHint for tools that create, update, delete, send, spend, or otherwise change external state. Add idempotentHint when retries with the same arguments are safe, and openWorldHint when the tool contacts systems outside your server. These annotations help Claude and reviewers reason about safety.
How do I test whether Claude calls my connector tools correctly?
Test at three layers: unit test the schema and handler, run inspector tests against simulated tool results, and do a live host smoke test with the deployed remote MCP server. sunpeak supports simulations, Playwright e2e tests, visual tests, live tests, and evals so you can catch tool metadata and rendering regressions before submission.
Can the same MCP server work as a Claude Connector and an MCP App?
Yes. A remote MCP server can expose data-only tools, interactive tools with MCP App resources, or both. The same server can be tested locally with sunpeak and then connected to Claude as a remote MCP connector, provided it is reachable over HTTPS and meets the host authentication and review requirements.