An MCP tool schema is more than a JSON object that passes a parser. It is the contract a client and a language model use to discover a capability, choose it at the right time, construct arguments, and interpret the result.
A weak contract may still look valid while producing bad calls. A vague description can make the model choose the wrong tool. An over-strict required list can block useful requests. An open-ended object can silently accept misspelled fields. A schema also cannot prove that the handler is authorized, safe, or correct.
This guide follows the Model Context Protocol tools specification dated 2026-07-28 and JSON Schema Draft 2020-12. That revision is the largest rework of the protocol since launch, and it changed enough around tool definitions that several older guides are now incomplete. We will build one tool definition, improve it deliberately, and finish with a validation workflow suitable for production work.
Start with the portable core
A practical MCP tool definition begins with a stable name, a precise description, and an inputSchema describing the call arguments.
{
"name": "search_docs",
"description": "Search product documentation and return the most relevant passages.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"query": {
"type": "string"
},
"limit": {
"type": "integer"
},
"include_archived": {
"type": "boolean"
}
},
"required": ["query"],
"additionalProperties": false
}
}
The MCP specification also supports optional fields such as a display title, icons, outputSchema, annotations, and extension metadata. Add them when the server and its clients can use them. Do not guess them merely to make the definition look complete.
If you want a deterministic starting point, the MCP Tool Schema Generator converts a representative JSON object into a minimal tool definition. It infers property types and nested shapes without calling an AI model, runs entirely in the browser, and needs no account. Treat the result as editable source, not a finished contract.
Choose a stable tool name
The current MCP guidance recommends names between 1 and 128 characters, treated as case-sensitive, using ASCII letters, digits, underscores, hyphens, or dots. A name should also be unique within its server.
Good names identify one action:
search_docsorders.get_statuscreate_support_ticket
Names such as helper, process, or run do not communicate enough intent. Avoid encoding a version in the name unless old and new contracts must remain available at the same time.
Two details make name stability more important than it used to be.
First, uniqueness is guaranteed only inside a single server. A client or proxy that aggregates several servers can easily end up with two search tools, and the specification expects it to disambiguate — typically by prefixing a server identifier. It also warns against relying on the server's own reported name for that purpose, since it is not guaranteed unique. If your tool is likely to be aggregated, a name that already reads as domain-specific will survive better than a generic one.
Second, tools/list results are now cacheable. A server can advertise a lifetime and a cache scope, and clients may hold the tool set for as long as that permits. A renamed tool therefore does not just break saved configurations, prompts, and allowlists at the moment you deploy it — it can stay broken in caches for a while afterwards.
Write a description that supports tool selection
The schema validates arguments, but the description helps the model decide whether it should call the tool at all. A useful description answers three questions:
- What action does the tool perform?
- What does it return?
- When should another tool be preferred?
Compare these descriptions:
Search documentation.
Versus:
Search public product documentation for matching passages. Returns document titles, URLs, and short excerpts. Useget_documentwhen the caller already has an exact document ID.
The second version separates discovery from retrieval and states the output shape. Keep behavioral rules in the description, but put machine-checkable limits in the schema.
Use an object schema for named arguments
Tool calls send named arguments, so an object root is the interoperable default. Even a no-argument tool should publish an object schema.
{
"name": "get_current_time",
"description": "Return the current server time in UTC.",
"inputSchema": {
"type": "object",
"additionalProperties": false
}
}
That closed form is the one the specification recommends for parameterless tools. A bare {"type": "object"} is also valid, but it accepts any object, including one carrying properties you never declared.
MCP uses JSON Schema Draft 2020-12 by default when $schema is absent. Declaring the dialect explicitly can still help reviewers and tooling understand which rules you intended.
Separate observed examples from real requirements
Generating a schema from sample JSON is useful, but an example cannot reveal business intent. Consider this sample:
{
"query": "refund policy",
"limit": 5,
"include_archived": false
}
It shows three property names and their observed types. It does not prove that all three are required, that limit can be any integer, or that an empty query is useful.
A reviewed schema might instead use:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Words or phrase to find in the documentation.",
"minLength": 2,
"maxLength": 300
},
"limit": {
"type": "integer",
"description": "Maximum number of passages to return.",
"minimum": 1,
"maximum": 20,
"default": 5
},
"include_archived": {
"type": "boolean",
"description": "Include archived documentation in the search.",
"default": false
}
},
"required": ["query"],
"additionalProperties": false
}
Only query is required. The optional fields have defaults, and the numeric range prevents an accidental request for millions of results.
Close object shapes intentionally
additionalProperties: false rejects undeclared keys. That is valuable for tool arguments because it catches mistakes such as include_archive instead of include_archived.
Strict objects also create a maintenance obligation. Adding a new field changes the accepted contract, so update the schema, handler, tests, and documentation together. For nested objects, decide separately whether each level should be closed.
Do not use additionalProperties: false automatically when arbitrary keys are part of the feature. A metadata map may legitimately accept user-defined property names. In that case, describe and constrain its values instead.
Review arrays and nullable values manually
Sample-based inference is weakest around arrays and null.
The first item in an array does not prove that every later item has the same shape. An empty array gives no evidence about items. A null example does not reveal the intended non-null type.
For a list of filters, describe the item contract and place useful bounds on the collection:
{
"type": "array",
"items": {
"type": "string",
"enum": ["guide", "reference", "changelog"]
},
"minItems": 1,
"maxItems": 3,
"uniqueItems": true
}
If a field accepts a string or null, express both types only when the handler actually supports both. Do not add nullability as a defensive habit.
Add an output schema when structured results matter
MCP tools may declare an optional outputSchema. When it is present, the server must return conforming structured data and the client should validate it.
{
"type": "object",
"properties": {
"matches": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"url": {"type": "string", "format": "uri"},
"excerpt": {"type": "string"}
},
"required": ["title", "url", "excerpt"],
"additionalProperties": false
}
}
},
"required": ["matches"],
"additionalProperties": false
}
Two practical notes that are easy to miss.
An output schema does not have to describe an object. Structured results may be any JSON value, and an array root is explicitly supported, so a list_users tool can declare an array of user objects directly rather than wrapping them in a single-key envelope. Wrap them only if the envelope earns its place — for example, because you expect to add pagination metadata later.
Structured results travel in a dedicated field, but for backwards compatibility a tool that returns them should also place the serialized JSON in a text content block. Clients that predate structured content still receive something usable.
An output schema improves validation, typed integrations, and documentation. It does not sanitize the returned content or make an untrusted URL safe. Output validation and output sanitization solve different problems.
Mirror parameters into headers deliberately
The 2026-07-28 revision introduced x-mcp-header, an extension property placed directly inside a property's schema. It asks the client to copy that argument's value into an HTTP header named Mcp-Param-{name} when the Streamable HTTP transport is used, so load balancers, proxies, and firewalls can route on it without parsing the request body.
{
"name": "execute_query",
"description": "Run a read-only analytics query in a specific region.",
"inputSchema": {
"type": "object",
"properties": {
"region": {
"type": "string",
"description": "Region the query executes in.",
"enum": ["us-west1", "eu-west1", "ap-south1"],
"x-mcp-header": "Region"
},
"query": {
"type": "string",
"description": "Read-only SQL statement to execute."
}
},
"required": ["region", "query"],
"additionalProperties": false
}
}
Called with "region": "eu-west1", the client adds Mcp-Param-Region: eu-west1 to the request.
This is worth knowing even if you never use it, because it is the one part of a tool definition that can make a client discard your tool entirely. Over Streamable HTTP, a client must reject any tool whose x-mcp-header value breaks the rules, and rejection means excluding that tool from the listing. Other tools on the same server keep working, so a single malformed definition shows up as one capability quietly missing rather than as an obvious failure. Clients are expected to log a warning naming the tool and the reason, which is usually where you will find the problem.
The constraints are specific:
- the value must not be empty, and must be a valid HTTP field-name token per RFC 9110;
- it must contain no control characters, including CR and LF;
- it must be unique, case-insensitively, among all
x-mcp-headervalues in thatinputSchema; - it may only be applied to integer, string, or boolean properties —
numberis not permitted, and integers must stay inside the IEEE 754 double-precision safe range; - the property must be statically reachable from the schema root.
There is also a judgment call the schema cannot make for you. Header values are visible to every network intermediary on the path, so passwords, API keys, tokens, and personal data should never be mirrored. Route on coarse, non-sensitive dimensions such as region, tenant, or workload class.
Design state handles as ordinary arguments
Because the protocol no longer keeps a session, a server cannot rely on implicit per-connection state to relate one call to the next. The specification's non-normative guidance is to make state explicit: a creation tool returns a handle, and later tools accept that handle as a normal argument. From the wire's point of view it is just a string.
{
"name": "add_item",
"description": "Add a product to an existing basket. Create one with create_basket first.",
"inputSchema": {
"type": "object",
"properties": {
"basket_id": {
"type": "string",
"description": "Handle returned by create_basket. Baskets expire after 24 hours of inactivity.",
"pattern": "^bsk_[a-zA-Z0-9]{8,}$"
},
"sku": {
"type": "string",
"description": "Product SKU to add."
}
},
"required": ["basket_id", "sku"],
"additionalProperties": false
}
}
Four design points follow directly from that pattern, and three of them are schema or description decisions:
Authorization is not implied by possession. On an authenticated server a handle is a name, not a capability, and the handler must check the caller's authorization against it on every call. On an unauthenticated server the handle is unavoidably a bearer token, so generate it with real entropy and give it a bounded lifetime.
Prefer opaque handles. Identifiers that encode internal structure invite parsing and guessing. The pattern above constrains the shape enough to catch a mangled value without advertising what is inside.
State the lifetime where the model can see it. Handles outlive any single connection, so the retention policy belongs in the creation tool's description. A model deciding whether to create state should be able to read how long that state survives.
Make expiry recoverable. A call against an unknown or expired handle should come back as a tool execution error that says so, not as a protocol error. That is the difference between a model that creates a fresh basket and one that gives up.
Treat annotations as hints, not permissions
MCP supports annotations describing tool behavior. The specification requires clients to treat them as untrusted unless they come from a trusted server.
An annotation saying that a tool is read-only does not replace authorization, user confirmation, access controls, or handler-side enforcement. Tool metadata describes intended behavior; the implementation must enforce actual behavior.
Validate in layers
No single green check proves that an MCP tool is production-ready. Use several layers.
1. Parse the JSON
Reject malformed JSON before checking MCP fields. This catches missing commas, invalid quotes, comments, and trailing commas.
2. Check the MCP tool structure
Verify the root object, name, description type, inputSchema, required, properties, and optional containers. The MCP Schema Validator performs this focused structural pass and returns path-based errors plus compatibility warnings. It is free and runs locally in the browser, so an unpublished tool definition never leaves your machine.
It deliberately does not claim to evaluate every JSON Schema keyword, resolve every reference, connect to a server, or call the handler.
3. Evaluate the full JSON Schema
Use a Draft 2020-12-compatible validator to test realistic valid and invalid argument objects. Include boundary values, missing required fields, extra fields, empty strings, Unicode, large arrays, and nested failures.
If your schema uses $ref, check how your validator resolves references and confirm it matches what MCP clients are expected to do — reference resolution is called out separately in the security guidance precisely because a schema that resolves differently on each side is a schema that validates differently on each side.
4. Test the actual MCP integration
List the tool through the production server and client, then call it with accepted and rejected inputs. Confirm authentication, authorization, user confirmation, timeouts, rate limits, error responses, and result validation. If you used x-mcp-header, confirm the tool actually appears in tools/list over Streamable HTTP rather than being silently dropped.
The MCP specification requires servers to validate tool inputs, implement access controls, rate-limit invocations, and sanitize outputs. Clients should show sensitive inputs before sending them, validate results, apply timeouts, and keep appropriate audit records.
Common failure patterns
One frequent mistake is copying every key from one sample into required. This turns convenient options into mandatory arguments and forces the model to invent values. Decide required fields from the operation's real minimum input, not from the example.
Another mistake is using schema descriptions as decoration. A property description should clarify units, identifiers, accepted sources, or consequences that the type alone cannot express. "The limit" adds little; "Maximum number of passages to return, from 1 to 20" helps both developers and models.
A newer failure mode is a tool that disappears without an error. If a client silently lacks one capability while the rest of the server works, check x-mcp-header values before anything else — a duplicate name, a control character, or a number-typed property is enough for a conforming client to drop that tool from the listing.
Do not assume a structurally valid tool is safe to expose. A delete_record schema may be perfectly formed while its handler lacks authorization or confirmation. Likewise, additionalProperties: false blocks unknown keys but does not protect against malicious content inside an allowed string.
Finally, avoid testing only the happy path. Calls fail because of expired credentials, inaccessible resources, timeouts, rate limits, conflicts, and invalid state. Return actionable tool execution errors where the model can correct the request, while keeping protocol and server failures distinct.
A practical release checklist
Before publishing a tool, confirm that:
- the name is stable, specific, and unique within the server, and reads sensibly if a proxy prefixes it;
- the description explains the action, result, and selection boundary;
-
inputSchemais a valid JSON Schema object with an object root; - only genuinely mandatory properties appear in
required; - strings, numbers, and arrays have realistic limits;
- nested objects and array items were reviewed manually;
- undeclared properties are either rejected or intentionally supported;
- any
x-mcp-headervalues are unique, token-safe, primitive-typed, and free of sensitive data; - state handles declare their lifetime in the description and are re-authorized on every call;
- optional
outputSchemamatches every structured result path, including array roots; - structured results are also serialized into a text block for older clients;
- annotations are never used as a security boundary;
- valid, invalid, boundary, authorization, and failure calls are tested;
- the handler validates inputs again and sanitizes outputs;
- sensitive operations remain visible and confirmable by the user.
Final takeaway
A useful MCP tool contract combines precise metadata, a deliberately reviewed JSON Schema, and behavior tested through the real server and client. Generate the repetitive starting structure, then spend human attention on the parts a sample cannot infer: optionality, constraints, semantics, side effects, permissions, and failure behavior.
That is the difference between JSON that looks like an MCP tool and a contract that clients can safely rely on.
Top comments (0)