Test MCP Tool Schemas, Defaults, Invalid Inputs, and Error Types
Test MCP tool schemas, omitted defaults, invalid inputs, protocol and execution errors, structured output, and product semantics under the current spec.

Effective MCP tool schema contract testing separates five questions that are often collapsed into one assertion: is the advertised JSON Schema valid, how does the server normalize omitted values, which failures belong in JSON-RPC errors, which failures belong in tool results with isError: true, and does a successful result satisfy both outputSchema and the product's meaning? Under the published 2025-11-25 MCP specification, these are related contracts with different authorities and different failure evidence.
Use the MCP server testing pillar for the complete architecture, then pair this guide with the official conformance server tutorial, GitHub Actions conformance baseline, and MCP Inspector CLI tutorial. Teams can find reusable QA instructions in /skills, install the Playwright CLI skill, and read the canonical Playwright MCP browser automation guide when tool contracts drive browser workflows.
Start with the current published contract
As of July 14, 2026, the MCP versioning documentation identifies 2025-11-25 as the current published protocol release. Draft material can describe future behavior, but it is not the baseline for a current release gate. Record the protocol revision, server SDK version, schema validator and dialect configuration, application commit, fixture revision, and tool catalog digest with every contract run.
The published tools specification establishes these relevant requirements and recommendations:
| Contract area | Published MCP behavior | What your tests must not assume |
|---|---|---|
inputSchema | Required JSON Schema object for every tool; defaults to dialect 2020-12 without $schema | A validator automatically inserts values from default |
outputSchema | Optional JSON Schema for structuredContent; when supplied, server results must conform and clients should validate | Text content alone proves the structured contract |
| Unknown tool or malformed call | Protocol error | Every invalid domain value should be a protocol error |
| Input validation, API, or business failure | Tool result with isError: true | JSON-RPC success means the tool succeeded |
| Tool annotations | Hints that clients must treat as untrusted from untrusted servers | readOnlyHint enforces authorization or prevents writes |
| Human control and security | Specification contains SHOULD and MUST guidance for confirmation, validation, access control, rate limits, sanitization, and audit | One universal prompt, timeout, or rate threshold fits every product |
Keep specification requirements, implementation behavior, security recommendations, and team policy labeled in test names and reports. For example, "output matches declared schema" is a protocol contract; "refund quotes expire after 15 minutes" is product semantics; "block release when any destructive tool lacks approval coverage" is team policy.
Define a representative tool contract
Use a real tool shape with enough constraints to expose type, omission, error, and output mistakes. The following orders.quote_refund definition uses explicit JSON Schema 2020-12, rejects unknown input properties as a team compatibility choice, annotates an optional currency, and declares structured output.
{
"name": "orders.quote_refund",
"title": "Quote an order refund",
"description": "Calculate an eligible refund quote without issuing the refund.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"orderId": {
"type": "string",
"pattern": "^ORD-[0-9]{4}$"
},
"currency": {
"type": "string",
"enum": ["USD", "EUR"],
"default": "USD"
},
"includeShipping": {
"type": "boolean",
"default": false
}
},
"required": ["orderId"]
},
"outputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"orderId": { "type": "string" },
"eligible": { "type": "boolean" },
"amount": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "enum": ["USD", "EUR"] },
"reasonCode": { "type": "string" }
},
"required": ["orderId", "eligible", "amount", "currency", "reasonCode"]
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
}
The root additionalProperties: false is not a universal MCP requirement. It is a compatibility policy that prevents misspelled or newly introduced fields from being ignored. Some extensible tools intentionally allow additional properties. Decide per tool and test the decision.
The annotations are discovery hints, not security controls. A server must still authenticate the caller, authorize access to the order, prevent cross-tenant data exposure, and ensure the supposedly read-only implementation does not mutate state.
Test schema validity before instance validity
There are two validation layers:
- Schema validation asks whether
inputSchemaandoutputSchemaare legal schemas in the declared or default dialect. - Instance validation asks whether a particular argument object or
structuredContentvalue satisfies that legal schema.
The MCP base specification says schemas without $schema default to JSON Schema 2020-12, implementations must support at least that dialect, and unsupported explicit dialects must be handled gracefully. Configure the validator for 2020-12; do not let a library silently interpret the schema as draft-07.
Build catalog tests for every advertised tool:
nameis unique and follows the published naming guidance;inputSchemais non-null, valid for its dialect, and has an object root;- every name in
requiredexists underpropertieswhen that is the intended schema structure; - examples and defaults validate against their containing subschemas;
- references resolve without network access in CI unless remote retrieval is explicitly controlled;
outputSchema, when present, is valid and has an object root;- annotations match observed behavior, even though they remain hints.
Use an explicit case matrix rather than one happy-path object:
| Case | Input | Schema expectation | Product expectation |
|---|---|---|---|
| Minimum valid | {"orderId":"ORD-1001"} | Valid | Server applies documented omission policy |
| Fully explicit | Order, USD, shipping false | Valid | Same normalized quote as minimum case if defaults are applied |
| Wrong JSON type | includeShipping: "false" | Invalid | Actionable tool execution error |
| Pattern violation | orderId: "1001" | Invalid | Actionable tool execution error |
| Unknown property | includeTax: true | Invalid under this schema | No silent ignore |
| Unsupported enum | currency: "GBP" | Invalid | Error identifies supported choices without leaking data |
| Valid shape, missing domain entity | ORD-9999 | Valid | Product not-found tool execution error |
| Valid and eligible | Fixture order ORD-1001 | Valid | Correct quote and schema-valid structured output |
Schema validation tells you whether the data shape is accepted. It cannot prove that the quote amount, tenant, eligibility, reason code, or authorization decision is correct.
Treat default as an annotation, then define server policy
The official JSON Schema annotation documentation states that default does not fill missing values during validation. It can communicate that absence is semantically equivalent to a value, and tools such as forms may use it as a hint. Therefore, MCP schema validation alone does not turn an omitted currency into "USD".
Choose and document one normalization policy:
| Policy | Omitted field behavior | Contract test |
|---|---|---|
| Server applies schema defaults | Server materializes USD and false before business logic | Omitted and explicit-default calls produce equivalent normalized audit input |
| Application defaults independently | Domain service owns the default; schema mirrors it | Unit test schema and application constants cannot drift |
| Absence has separate meaning | Omitted differs from explicit value | Test both paths and remove misleading default annotation |
| Field is actually required | Server rejects omission | Add field to required; do not rely on prose |
Test omission, explicit default, non-default, explicit null, and wrong type separately. Null is not absence. If the schema type is string, null is invalid unless the schema explicitly permits it.
{
"cases": [
{
"id": "currency-omitted",
"arguments": { "orderId": "ORD-1001" },
"normalized": { "orderId": "ORD-1001", "currency": "USD", "includeShipping": false }
},
{
"id": "currency-explicit-default",
"arguments": { "orderId": "ORD-1001", "currency": "USD", "includeShipping": false },
"normalized": { "orderId": "ORD-1001", "currency": "USD", "includeShipping": false }
},
{
"id": "currency-explicit-null",
"arguments": { "orderId": "ORD-1001", "currency": null },
"expectedError": "tool-execution"
}
]
}
The normalized field is a fixture expectation, not an MCP message field. Observe normalization through a test seam or sanitized audit event; do not add private diagnostic data to production tool results merely for testing.
Distinguish malformed requests from invalid tool inputs
The current tools specification defines two error mechanisms. Protocol errors are standard JSON-RPC errors for an unknown tool, a malformed CallToolRequest, or a server-level protocol failure. Tool execution errors are ordinary JSON-RPC results with isError: true for input validation, upstream API failures, and business logic errors.
That means "invalid input" needs a precise definition:
| Failure | Example | Correct channel under current tools spec |
|---|---|---|
| Malformed request envelope | params is a string, or required request structure is absent | JSON-RPC protocol error |
| Unknown tool | name: "orders.missing" | JSON-RPC protocol error |
| Arguments violate tool schema | Boolean supplied as string | Tool result with isError: true |
| Arguments pass schema but violate domain rule | Refund window closed | Tool result with isError: true |
| Dependency rejects or times out | Order service unavailable | Tool result with isError: true |
| Caller lacks product permission | Valid identity cannot access order | Product/security design; normally actionable execution error without leaked data |
| MCP endpoint lacks valid authorization | Missing or invalid HTTP credential | Authorization or transport response before tool execution |
Do not require -32602 for every schema-invalid argument. The 2025-11-25 changelog explicitly clarifies that input validation errors should be tool execution errors so a model can correct its call. Reserve protocol errors for the malformed or unknown protocol-level cases described by the specification.
These two JSON documents show the shape difference. The first is a protocol error for an unknown tool:
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32602,
"message": "Unknown tool: orders.missing"
}
}
The second is a tool execution error for a known tool with a domain-invalid order identifier:
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"content": [
{
"type": "text",
"text": "orderId must use the form ORD-0000"
}
],
"isError": true
}
}
Error messages should be actionable enough for correction but must not expose stack traces, credentials, tenant existence, database queries, or internal network details. Exact public error codes and redaction rules are product and security contracts.
Validate structuredContent and its text fallback
structuredContent is a JSON object in a tool result. When the tool advertises outputSchema, the server must provide structured results conforming to it, and clients should validate them. The tools specification also recommends returning serialized JSON in a text content block for backward compatibility.
A valid success fixture for the refund quote could be:
{
"content": [
{
"type": "text",
"text": "{"orderId":"ORD-1001","eligible":true,"amount":42.5,"currency":"USD","reasonCode":"WITHIN_WINDOW"}"
}
],
"structuredContent": {
"orderId": "ORD-1001",
"eligible": true,
"amount": 42.5,
"currency": "USD",
"reasonCode": "WITHIN_WINDOW"
},
"isError": false
}
Test at least these output defects:
structuredContentis absent despite an advertised output schema;- amount is a numeric string instead of a number;
- a required field is missing;
- currency falls outside the enum;
- an undeclared property appears when output forbids additional properties;
- text fallback is missing or serializes different data;
- result is schema-valid but refers to the wrong order;
isErroris true while the payload looks like a successful quote.
If error results need structured machine-readable fields, define and document an error contract deliberately. Do not assume a success-only outputSchema automatically describes every isError: true payload. The current specification's broad conformance statement should be tested against your SDK behavior and tool design, and ambiguous error unions should be made explicit in product documentation.
Add transport-neutral contract assertions
Keep core assertions independent of stdio or HTTP so the same matrix can run through an SDK client, Inspector capture, or another harness. The following TypeScript is syntactically complete and receives an injected request function; it does not guess a changing SDK constructor.
import assert from 'node:assert/strict';
type JsonObject = Record<string, unknown>;
type Invoke = (request: JsonObject) => Promise<JsonObject>;
export async function verifyRefundContracts(invoke: Invoke) {
const unknownTool = await invoke({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'orders.missing', arguments: {} },
});
assert.equal(typeof unknownTool.error, 'object');
const invalidInput = await invoke({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'orders.quote_refund',
arguments: { orderId: 'ORD-1001', includeShipping: 'false' },
},
});
const invalidResult = invalidInput.result as JsonObject;
assert.equal(invalidResult.isError, true);
const valid = await invoke({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'orders.quote_refund',
arguments: { orderId: 'ORD-1001' },
},
});
const result = valid.result as JsonObject;
assert.notEqual(result.isError, true);
const output = result.structuredContent as JsonObject;
assert.equal(output.orderId, 'ORD-1001');
assert.equal(output.currency, 'USD');
assert.equal(typeof output.amount, 'number');
assert.equal(typeof output.eligible, 'boolean');
}
Add your chosen JSON Schema 2020-12 validator before the semantic assertions. The injected invoke adapter should preserve the full JSON-RPC response so the test can distinguish error from result.isError. Do not write an adapter that converts every failure into a thrown JavaScript exception; that erases the protocol channel under test.
Test product semantics after schema success
A schema-valid response can still be dangerously wrong. For the quote tool, assert:
- the returned order belongs to the authorized fixture tenant;
- eligibility matches refund-window and order-state rules;
- amount uses the approved decimal and rounding policy;
- currency comes from the order or documented default policy;
- shipping inclusion follows the explicit argument;
- the quote operation creates no refund or payment side effect;
- reason code maps to a documented user-safe explanation;
- repeated calls are consistent with the advertised idempotency hint;
- unavailable dependencies produce a safe execution error rather than a fabricated quote.
These are product requirements. MCP transports them but does not define refund arithmetic, tenant design, or idempotency keys. Assign business owners to expected values and security owners to authorization and leakage cases.
Use mutation tests against the fixture: change the order tenant, state, purchase date, currency, item total, shipping amount, and prior refund status one field at a time. A mutation should change only the outcomes implied by the rule. This catches handlers that return a hard-coded schema-valid fixture.
Include security and team policy without overstating the spec
The tools specification says servers must validate inputs, implement access controls, rate limit invocations, and sanitize outputs. It recommends that clients confirm sensitive operations, show inputs, validate results, use timeouts, and log tool use. Those statements establish direction, but exact limits remain implementation and team decisions.
| Control | Specification or recommendation | Example team policy |
|---|---|---|
| Input validation | Server MUST validate | Validate before any dependency call and log only field names on rejection |
| Access control | Server MUST implement proper controls | Every order lookup is tenant-scoped and deny-by-default |
| Rate limiting | Server MUST rate limit | Limit chosen from abuse model and service capacity, not a copied universal number |
| Output sanitization | Server MUST sanitize | No stack, credential, raw SQL, or cross-tenant identifier in errors |
| Confirmation | Client SHOULD confirm sensitive operations | Refund execution requires explicit user confirmation; quote does not |
| Timeout | Client SHOULD implement | Per-tool budget based on observed latency and safe cancellation behavior |
Test both presence and effectiveness. A rate limiter that always permits calls is not proven by configuration. An annotation claiming read-only behavior is not proven until a state-diff assertion shows no mutation.
Run contracts in CI and release review
Organize the job into catalog, call-shape, error-channel, structured-output, product, and security phases. Save normalized request and response fixtures with secrets removed. Run contract tests on every tool schema or handler change, every MCP SDK upgrade, and every product rule change that affects tool output.
Block release on protocol contract failures. Whether a product-semantic or security test can be temporarily waived is a risk decision, but waivers need owner, impact, compensating control, and expiry. Never convert a wrong error channel or cross-tenant result into a broad expected snapshot.
Compare catalog digests across releases. A removed tool, newly required input, narrowed enum, changed default meaning, or incompatible output schema should trigger compatibility review. Adding an optional output property can still break strict consumers if they apply additionalProperties: false locally, so publish change notes even for theoretically additive updates.
Troubleshooting failures by layer
Schema compiles in one environment but not another. Confirm both validators use the declared dialect. MCP defaults an absent $schema to 2020-12; a draft-07 default in a library is a configuration mismatch.
Omitted values differ between Inspector and an SDK client. Compare the actual arguments sent. A form may materialize an explicit default while CLI invocation omits the key. Decide whether normalization belongs to the server and test both request shapes.
Invalid arguments return a JSON-RPC error. Check whether the request itself is malformed or only violates the tool's input schema. Under the current tools guidance, input validation belongs in a tool execution error so a model can retry.
Structured output validates but the text fallback differs. Serialize from one normalized result object instead of building text and structured forms independently. Test semantic equality after parsing the text JSON.
The success response validates but contains the wrong tenant's order. This is a critical product and access-control failure, not a schema problem. Stop the release and inspect authorization scope before changing expected fixtures.
Annotations disagree with behavior. Treat annotations as untrusted hints and fix either the implementation or metadata. Never use the hint as the only authorization or confirmation control.
Limitations
Contract tests cover declared tools and selected fixtures. They cannot prove every JSON Schema instance, every dependency response, or every attack path. Property-based generation and fuzzing can broaden input coverage, but generated cases still need domain oracles. Load and concurrency tests are separate because a functionally valid tool may race or exhaust resources under parallel calls.
Schema compatibility is also consumer-dependent. MCP defines message shapes and guidance, while a particular client may have stricter code generation or rendering behavior. Test the supported clients that matter to your product without rewriting those client quirks as universal protocol rules.
Contract checklist
- Pin and record the published MCP protocol baseline
2025-11-25. - Validate each schema against its declared or default 2020-12 dialect.
- Test valid, omitted, explicit-default, null, wrong-type, enum, pattern, and extra-field inputs.
- Define who applies defaults; do not expect validation to insert them.
- Assert malformed requests and unknown tools through protocol errors.
- Assert schema, dependency, and business failures through
isError: trueresults. - Validate
structuredContentwheneveroutputSchemais advertised. - Compare the serialized text fallback with structured data.
- Add product semantics and tenant authorization after schema validation.
- Verify annotations against behavior while treating them as hints.
- Preserve sanitized fixtures, responses, versions, and catalog digests in CI.
- Review every schema change for backward compatibility and release impact.
Frequently asked questions
Which JSON Schema dialect does MCP use for tool schemas?
The published 2025-11-25 specification defaults schemas without $schema to JSON Schema 2020-12. Implementations must support that dialect and should document additional dialects. Explicitly configure your validator instead of relying on its library default.
Does the default keyword insert a missing tool argument?
No. JSON Schema defines default as an annotation, not a mutation performed by validation. A form or server may choose to apply it. Document that normalization policy and test omitted and explicit values separately.
Should a schema-invalid tool argument return JSON-RPC -32602?
Not under the current tools guidance when the tools/call request is structurally valid and the known tool's argument fails validation. Return a tool execution error with isError: true and actionable content. Reserve protocol errors for unknown tools, malformed requests, and protocol-level server failures.
Is structuredContent required for every tool?
Tools can return unstructured content without an output schema. When a tool advertises outputSchema, the server must provide conforming structured results, and clients should validate them. The specification recommends a serialized text block for backward compatibility.
Does outputSchema prove the result is correct?
No. It proves only that the structured value has the declared shape. A refund amount can be a valid number and still be wrong, unauthorized, or calculated from another tenant's order. Add product and security assertions after schema validation.
Are readOnlyHint and destructiveHint security controls?
No. Tool annotations are hints and must be treated as untrusted when they come from untrusted servers. Enforce authorization, confirmation, isolation, and side-effect controls in the client and server, then test observed behavior.
Should error results conform to the success output schema?
Do not assume a success-only schema explains every error payload. Define an explicit structured error design if clients need one, test it against your SDK behavior, and always preserve the required distinction between protocol errors and tool execution errors.
How often should MCP tool contracts run?
Run them on every change to tool registration, schemas, handlers, product rules, MCP SDK, authorization, or transport adapters. Include the stable conformance suite as a separate job and run broader security, fuzz, and load coverage according to release risk.
Conclusion
A durable MCP tool contract is layered. Validate the advertised schema and dialect, define omission and default behavior, preserve the protocol-versus-execution error boundary, verify structured results, and then assert the product's real meaning and security rules. Keeping those layers separate produces failures that are diagnosable and release claims that match what the tests actually prove.