MCP server best practices: tools, descriptions, hints and errors that pass review
What are the best practices for building an MCP server?
Design a few tools around the jobs your users do, not one per API endpoint. Give each a plain verb name, a description that says when to use it, and explicit read-only, destructive and open-world hints. Keep reads and writes in separate tools, return only what the request needs, and make errors say how to fix the call. Invokary checks servers against these rules.
- Published
These practices come from three places: the MCP specification, the rules Claude's and OpenAI's reviewers check before they list a connector, and Anthropic's engineering guidance on writing tools for agents. The examples come from our own MCP server, which serves our Platform Requirements dataset at mcp.invokary.com. It is open source, listed in Claude's Connectors Directory and published in the MCP Registry. The code below is from its version 1.0.0, built on the MCP TypeScript SDK, and the outputs are what the live server returned when we called it.
1. Design tools around jobs, not endpoints
An agent picks tools by reading them, so a long list of thin endpoint wrappers makes it work harder and choose worse. Anthropic's guidance is to build tools for whole workflows: one schedule_event tool rather than separate tools to list users, list events and create an event. It also prefers a search tool over one that lists everything.
Our server has six tools over one dataset. One of them, search_requirements, answers a question like "do I need a privacy policy?" in one call, instead of making the agent page through every platform's list. Our guide to MCP vs API shows how to turn a set of endpoints into a small set of such tools.
2. Name every tool plainly, and give it a title
- Names. Use unique, plain verbs. OpenAI's own example is
get_order_status. Claude requires tool names of 64 characters or fewer. Ours arelist_platforms,get_platform_requirements,get_requirement,compare_platforms,search_requirementsandlist_platform_changes. - Titles. Claude requires a title on every tool, the human-readable name a client shows. "Get one requirement" is the title of
get_requirement.
3. Write descriptions that say what and when, never how to behave
A description is the only thing the model reads before it decides to call your tool. Claude's reviewers check that it says what the tool does and when to use it, and that it doesn't tell Claude how to behave: "Use this tool to look up an invoice by number" passes, "Always call this tool first" does not. OpenAI adds that the description matches the tool's schema and behavior, including its limits and side effects, and that metadata never steers the model away from other plugins.
Ours end with the "when". The description of list_platforms closes with: "Use it to see what the dataset covers and which ids the other tools take."
4. Set every hint explicitly
Hints tell the client what a call can do before it makes one.
- Claude requires the applicable hint on every tool:
readOnlyHintfor tools that only read,destructiveHintfor tools that change or delete data. - OpenAI requires all three of
readOnlyHint,openWorldHintanddestructiveHint, as explicit booleans, with a justification for each.
Setting all of them on every tool satisfies both. Our tools only read a fixed dataset, so one helper sets the same hints on all six:
// Every tool only reads a bundled, fixed dataset: no writes, no deletes, no outside calls.
const readOnly = (title: string) => ({ title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
server.registerTool(
"get_requirement",
{
title: "Get one requirement",
description:
"Returns one requirement by its id, for example claude.rule-annotations, with its sources and the date it was last checked. For a retired id it returns the retirement date and the requirement that replaced it.",
inputSchema: z.object({ id: z.string().min(1).max(100).describe("The requirement id, as returned by the other tools.") }),
annotations: readOnly("Get one requirement"),
},
async ({ id }) => {
// Look the id up and return the requirement, or a tool error (section 7).
},
);A client listing the tools sees this, as returned by the live server:
{
"name": "get_requirement",
"title": "Get one requirement",
"annotations": {
"title": "Get one requirement",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
}One caution from the specification: clients treat hints as untrusted unless the server is trusted. Hints describe your tool; they don't secure it. Sign-in, access control and a confirmation step for irreversible actions still have to exist.
5. Keep reads and writes in separate tools
Claude rejects a single tool that mixes safe and unsafe HTTP methods, such as a catch-all api_request tool, and OpenAI rejects generic executors that hide what a call does. Split them: get_invoice and void_invoice, not invoice_action with a method argument. Each tool can then carry honest hints, and a client can ask for confirmation before only the dangerous one.
6. Keep inputs small and results smaller
- Inputs. Ask only for what the job needs. OpenAI rejects tools that request the full chat history, earlier turns or a precise location. Use enums for fixed values: our
platformargument accepts only the five ids the dataset has, so a guessed value fails fast with a clear message. - Results. Return only what the request needs. OpenAI's guidelines rule out internal IDs, trace IDs and telemetry in responses. Anthropic's guidance adds names over opaque identifiers, plus filters and pagination for anything large. Our
get_platform_requirementstakes optional topic and product-surface filters for that reason. - Structure. The specification lets a tool declare an output schema and return structured content next to its text. Ours do both, so a client can render the data and a model can read the text.
7. Make errors tell the agent what to do next
The specification separates two kinds of error. A protocol error, such as a call to a tool that doesn't exist, is a JSON-RPC error. A tool execution error, such as bad input, comes back as a normal result with isError: true, which the model can read and act on. Anthropic's advice is to make that message actionable.
Here is what our server returned for an id with a typo:
{
"isError": true,
"content": [
{
"type": "text",
"text": "No requirement has the id \"claude.rule-annotation\". Close matches: claude.rule-annotations, claude.rule-name-length, claude.rule-descriptions, claude.rule-split-tools, claude.rule-test-credentials."
}
]
}The agent can retry with the right id without asking the user.
8. Secure the transport and the sign-in
- Transport. A remote server uses Streamable HTTP: one endpoint that accepts POST. The specification requires servers to validate the
Originheader against DNS rebinding, and a server running on your own machine should bind only to127.0.0.1. - Sign-in. If the server reaches user data, use OAuth, and never pass the user's token through to another service. Our guides to MCP OAuth for SaaS connectors and MCP connector security cover both.
- Server duties. The specification's tools page says servers must validate inputs, enforce access controls, rate-limit calls and sanitize outputs. Ours rate-limits each IP address at the edge.
9. Treat tool metadata as a public API, and test it on every release
Directories keep reading your tools after launch: OpenAI fetches them again on a schedule, and a changed tool goes live only once it passes automated checks. A renamed tool or a rewritten description is a release, so check it like one. The MCP Inspector's command-line client makes that a single step in CI:
npx @modelcontextprotocol/inspector --cli https://mcp.invokary.com/mcp --transport http \
--method tools/list --strict --format jsonIt exits with 0 when the list loads and the tool schemas are portable, 6 when --strict finds a schema problem, 4 when the server can't be reached, and 3 when it needs sign-in. Against our server it exits with 0.
Checklist
- Each tool does one job a user would name, and the list is short enough to read.
- Every tool has a unique verb name of 64 characters or fewer, and a title.
- Every description says what the tool does, when to use it, and its limits and side effects, and gives the model no orders.
- Every tool sets
readOnlyHint,destructiveHintandopenWorldHintexplicitly, and you can justify each value. - No tool both reads and writes, and none takes an HTTP method or an endpoint as an argument.
- Inputs use enums for fixed values and never ask for chat history or a precise location.
- Results carry no internal IDs or telemetry, and large lists filter or paginate.
- Bad input returns
isError: truewith a message that says how to fix the call. - The remote endpoint validates
Origin, signs users in with OAuth, and rate-limits. - CI lists the tools with the Inspector on every release.
Where we come in
A Connector Launch Sprint builds a server this way from the start. If you already have one, the Agent Distribution Audit checks it against this list and against each directory's rules on our Claude and ChatGPT pages.
Tested with: 2026-07-28, MCP TypeScript SDK (@modelcontextprotocol/server) 2.3.0, on .
Sources
- MCP 2026-07-28: Tools (accessed )
- MCP 2026-07-28: Streamable HTTP (accessed )
- MCP 2026-07-28: Specification overview (accessed )
- MCP Inspector: CLI client (accessed )
- Anthropic Engineering, September 11, 2025: Writing effective tools for AI agents (accessed )
- Claude docs: Connector pre-submission checklist (accessed )
- OpenAI Developers: Plugin guidelines (accessed )