Guide

How to write an MCP tool spec Claude can actually run safely

The Model Context Protocol tells you how a tool call travels over the wire. It does not tell you what to write so the model calls your tool at the right moment, with the right arguments, and cannot be talked into using it for something you never intended. That gap is where most bad MCP tools live.

What the protocol settles, and what it leaves to you

The specification settles the protocol-level facts: which primitives a server offers, which of them the model controls, and the rules for tool names. It leaves the quality of the tool to you: whether the description is vague, whether the arguments are typed, and whether the error set has more than one member. Tools are one of three server primitives, alongside resources and prompts, and they are the model-controlled one: "Tools in MCP are designed to be model-controlled". Tool names should be between 1 and 128 characters, and should be treated as case sensitive.

None of that tells you whether your tool is any good. The protocol will happily carry a vague description, untyped arguments, and an error set with one member called success. The eight-section structure below is not a published standard but house doctrine, defended in what follows rather than asserted.

One function, one job

One callable tool, one job: no mode flag, and nothing that fetches and then also sends. The reason is selection. A tool that does three things needs a description covering three decision boundaries, and every extra boundary is a chance to call it when it should not. There is a protocol reason too: "MCP has no protocol-level session, so a server cannot rely on implicit per-connection state to relate one tool call to the next." If a job needs state across calls, return an explicit handle from a creation tool and take it as an argument later.

Four contradictions are worth checking before you write anything:

  • The request reads as an autonomous agent rather than a single function.
  • More than one tool is implied, so what you want is a multi-tool server.
  • A local stdio server is described but the scenario is multi-user, which needs Streamable HTTP.
  • The signature, return shape or error set was left empty.

The eight sections

An MCP tool spec has eight sections, and each one pins a specific thing to prevent a specific failure.

Section What it must pin The failure it prevents
TOOL_IDENTITY The one job in a sentence, plus what it explicitly does not do Drift from a function into an agent
FUNCTION_SIGNATURE Exact name, every parameter, type, required or optional, constraints The model guessing argument shapes
TOOL_DESCRIPTION The text read at decision time, including the observable trigger condition A correct tool that never gets called
DATA_SOURCES Every system read or written, with the credential and scope on each A tool holding more access than its job needs
BUSINESS_LOGIC The ordered steps from input to output, including validation Behaviour that lives only in the code
OUTPUT_CONTRACT The exact success shape, field by field, plus the complete error set A tool that can only succeed
SECURITY_GUARDRAILS What the tool refuses, why, and what needs a recorded approval The tool being argued into doing damage
DEPLOYMENT_CONFIG Transport, where it runs, authentication, operational limits Building for one client a job that needs many

Each section constrains the next: no signature until the job is single, no description until the signature is fixed, no guardrails until you know the data sources.

The description is a prompt, not documentation

The tool description is not a README. Along with the name and the input schema, it is all the calling model sees when it decides whether to invoke your tool. The business logic, the data sources and the retry policy are invisible then. When the description is vague, the failure is often not a wrong call but no call at all: a correct tool sitting unused while the model answers from memory. Write it as an instruction:

  • Name the specific observable condition that should trigger a call. Never "when needed" or "for questions about X". Something checkable, like a request that names a customer identifier.
  • Say what the tool does not do, so the model can rule it out quickly.
  • Make the name carry weight: search_ finds many, get_ fetches one, and verbs like send_, create_ and score_ signal an effect on the world.
  • Give the per-argument descriptions in the schema the same care. They are read at the same moment.

One caution from the spec: "For trust & safety and security, clients MUST consider tool annotations to be untrusted unless they come from trusted servers." The description guides selection, and a client is entitled to distrust it. Anything that must hold has to hold in the server.

A tool that can only succeed is a tool that will lie

Enumerate the error set. An empty error array is the defect this section exists to prevent. If success is the only documented outcome, the model has no vocabulary for the difference between no rows matched, the upstream system is down, you asked for a record you are not allowed to see, and that argument was malformed. A tool that cannot report failure does not stop failing. It starts fabricating.

Errors split in two: protocol errors for unknown tools and malformed requests, and tool execution errors reported inside the result with isError set true. The spec's justification for the second kind is that it carries actionable feedback the model can use to self-correct and retry with adjusted parameters. That only works if the feedback is specific: a distinct code and message for a not-found result, an authorisation refusal, an upstream failure and an invalid argument, one more than the three error codes the doctrine floors into every tool spec. Each needs a named next step, such as retry once and then return the cached value, never the phrase handle the error.

Guardrails go in the spec, not in the review

Write every guardrail into the tool itself, as a rule with a stated reason, rather than assuming the host will show a confirmation dialog.

The specification is direct about the risk surface, describing a protocol that enables powerful capabilities through arbitrary data access and code execution paths, and it recommends a human backstop: "For trust & safety and security, there SHOULD always be a human in the loop with the ability to deny tool invocations." Do not lean on it: you do not control which host runs your server, so write the constraint into the tool rather than assuming a confirmation dialog.

Give every rule a reason: never X because Y. A bare prohibition is easy for a model to reason around under pressure, and one with a stated cause is not. Cover data privacy, scope, impersonation and irreversible actions, specific to your own data: never log full customer records because they contain PII subject to retention limits; never send, delete, pay or publish without a recorded approval, because the action cannot be undone. A gate that exists only in the UI is not a gate.

Deployment config, and one stale assumption to avoid

Deployment config starts with the transport, and the transport decides which rules bind your implementation: stdio typically serves a single client and must keep stdout clean, while Streamable HTTP typically serves many and must validate the Origin header. The stale assumption to avoid is a template built around an initialize handshake or a session identifier, which describes the legacy model.

Transport is a design decision, not packaging. The docs put the split cleanly: "Local MCP servers that use the STDIO transport typically serve a single MCP client, whereas remote MCP servers that use the Streamable HTTP transport will typically serve many MCP clients."

Choose stdio and one rule leaks into your implementation: "The server MUST NOT write anything to its stdout that is not a valid MCP message." A stray debug print corrupts the channel, so logging goes to stderr.

Choose Streamable HTTP and the security floor is explicit: "Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks", and "When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)."

Then check your template's age. Revision 2026-07-28 makes MCP a stateless protocol where every request carries its own protocol version and capabilities, the versioning page says there is no negotiation handshake, and the transport callout lists removal of the GET stream endpoint and of protocol-level sessions. A section built around an initialize handshake or a session identifier is describing the legacy model.

Where this fits if you use Bespoke Prompting

The MCP Tool build type emits those eight sections in that fixed order. Its classification is deterministic and synchronous, with no LLM stages in the engine at all, the tool count is derived as one because of the one tool one job rule, and it surfaces the four contradictions named above rather than silently building what you described.

Sources

Suggested internal links