Chapter 276
MCP Tool Contracts and Content
MCP Tool Contracts and Content
A tool is safe to automate only when discovery, arguments, results, pagination, and transport metadata agree on one contract.
Type: Build Languages: Python Prerequisites: Phase 13, Lessons 07, 09, and 10 Time: ~120 minutes
Learning Objectives
- Define tool inputs and outputs with JSON Schema 2020-12.
- Validate structured results without assuming they are JSON objects.
- Choose between text, image, audio, resource links, and embedded resources.
- Reject unsafe
x-mcp-headerdefinitions before a tool reaches the model. - Encode parameter-header values and verify exact header-to-body parity.
- Traverse cursor pagination without interpreting cursor values.
- Bound and authorize
completion/completesuggestions.
The Problem
Calling a Python function is easy. Calling a remote capability through an AI host is a contract problem.
The server publishes a descriptor. The client turns that descriptor into model context and user interface. The model creates arguments. A gateway may route the request from mirrored headers. The server executes the tool. The client then decides whether the result is safe and valid enough to return to the model.
One weak boundary corrupts the whole chain.
Consider five failures:
- The descriptor says the result is an object, but the server returns an array.
- The client stops pagination when
nextCursoris an empty string. - A token parameter is mirrored into an HTTP header and becomes visible to intermediaries.
- A Unicode routing value is sent as a raw header, then the gateway and origin interpret different bytes.
- A completion endpoint suggests a production environment to a caller who cannot access it.
None of these failures is fixed by better prompting. They require explicit protocol and application contracts.
The Contract Pipeline
Treat each tool call as five gates:
- Discover. Read a deterministic, paginated tool list.
- Admit. Validate each descriptor and apply local security policy.
- Invoke. Validate arguments and build transport metadata.
- Execute. Run the handler and classify failures correctly.
- Consume. Validate content blocks and structured output before model use.
mcp-contract-pipelineThe host owns the admission and consumption gates. A server cannot force a client to trust its annotations, schemas, or outputs.
JSON Schema Is a Runtime Boundary
In MCP 2026-07-28, inputSchema and outputSchema use JSON Schema. When $schema is absent, the default dialect is 2020-12.
The input schema must be a schema object. A tool with no arguments should still say exactly what it accepts:
{
"type": "object",
"additionalProperties": false
}This is stricter than { "type": "object" }, which accepts arbitrary properties.
An output schema is optional. Once a server publishes one, every complete tool
result commits to returning conforming structuredContent, including results
with isError: true. The error flag classifies execution outcome; it does not
waive the published output contract. Clients should validate the result instead
of trusting the descriptor.
Structured content is any JSON value
Do not hard-code structuredContent as a dictionary. It can be:
- an object;
- an array;
- a string;
- a number;
- a boolean;
null.
This tool returns an array:
{
"name": "tag_catalog",
"inputSchema": {
"type": "object",
"additionalProperties": false
},
"outputSchema": {
"type": "array",
"items": {"type": "string"}
}
}Its successful result is valid:
{
"resultType": "complete",
"content": [
{
"type": "text",
"text": "[\"contracts\", \"mcp\", \"stateless\"]"
}
],
"structuredContent": ["contracts", "mcp", "stateless"],
"isError": false
}For compatibility, structured results should also include serialized JSON in a text block. The text is not the validation source. structuredContent is.
A small validator still teaches the boundary
The lesson uses a deliberate JSON Schema subset because it stays inside the Python standard library. It checks the mechanisms used by the sample tools:
- object, array, string, integer, number, boolean, and null types;
- required properties;
additionalProperties: false;- array items;
- enum values;
- minimum string length.
This is not a replacement for a complete production validator. The reusable lesson is where validation happens: after discovery for descriptors, before execution for arguments, and before consumption for structured results.
Content Blocks Carry Different Costs
The content array can combine several content types.
| Type | Use it for | Main boundary |
|---|---|---|
text | Human and model-readable summaries | Treat text as untrusted output |
image | Visual evidence encoded as base64 | Validate media type and size |
audio | Spoken or recorded output encoded as base64 | Validate media type and duration limits |
resource_link | A URI the client may fetch later | Reauthorize the later resource read |
resource | Data embedded directly in the result | Enforce payload and content limits now |
A resource link is not proof that the resource appears in resources/list. It is a reference returned by this tool call. The client still applies its resource policy when it follows the URI.
An embedded resource avoids another round trip but increases the current response size. Use links for large or independently changing artifacts. Use embedded resources for small evidence that must travel atomically with the result.
The lesson's evidence_bundle result includes all five types. The client validates each block before accepting the result.
x-mcp-header Is Routing Metadata
A property inside inputSchema may declare x-mcp-header. Over Streamable HTTP, the client mirrors that argument into Mcp-Param-{name}.
{
"region": {
"type": "string",
"x-mcp-header": "Region"
}
}With region: "eu-west", the transport can emit:
Mcp-Param-Region: eu-westThe annotation exists so a load balancer, gateway, or policy engine can route without parsing the JSON body. It is not a place to put credentials.
The protocol constrains the annotation:
- the header name is non-empty and follows HTTP field-name token syntax;
- header names are unique without regard to case;
- the property type is string, integer, or boolean;
numberis not allowed;- the annotation appears only on a direct member of
inputSchema.properties; - integer values stay within
-9007199254740991through9007199254740991.
The location rule is syntactic and fail-closed. Walk the entire schema tree,
not just the properties your validator happens to understand. Reject an
annotation under a nested object's properties, a oneOf branch, items, a
definition reached by $ref, or any output schema. Resolving a reference does
not turn the referenced node into a direct top-level property.
This lesson adds a deployment policy: reject descriptors that mirror names such as password, secret, token, api_key, or authorization. The official specification advises server authors not to mirror sensitive parameters. A client can turn that advice into a hard admission rule.
Audit the header name, not its value. The sample code records Mcp-Param-Region while keeping eu-west out of the audit event.
Encode values before building HTTP headers
A parameter value may travel as plain text only when it is a non-empty string
of visible ASCII characters from ! through ~ and does not resemble the
encoding sentinel. Everything else uses this exact form:
=?base64?{Base64UTF8}?=Base64UTF8 is standard base64 over the exact UTF-8 bytes. Do not trim,
normalize, or replace the value first. Encode Unicode, empty strings, spaces,
tabs, control characters, CR or LF, leading or trailing whitespace, and any
value beginning with =?base64?. Encoding a sentinel-looking value again is
what lets the receiver recover the literal original text instead of decoding
it as transport syntax.
Booleans render as lowercase true or false. Integers render in base 10 and
must stay inside the JavaScript safe integer range. Values outside that range
are rejected instead of rounded by an intermediary.
The server checks the mirrored copy
Header generation is only the client half. At the Streamable HTTP boundary, the server must:
- find recognized
Mcp-Param-*names without regard to header-name case; - decode the exact base64 sentinel form when present;
- compare the decoded text with the corresponding JSON body argument exactly;
- reject a missing, duplicated, unexpected, malformed, or mismatched recognized header before dispatch.
The rejection is HTTP 400 with JSON-RPC error code -32020. Neither the
body value nor its encoded header form belongs in the audit record. Record the
recognized header name and the rejection category only.
code/main.py models this boundary directly. Lesson 09
covers the wider Streamable HTTP validation order, including method and
protocol-version parity.
Pagination Cursors Are Opaque
MCP list operations use cursor pagination. The server selects page size and cursor format. The client gets one decision:
if result.get("nextCursor") is None:
break
cursor = result["nextCursor"]Do not write this:
if not result.get("nextCursor"):
breakAn empty string is a valid cursor. Truthiness would stop too early.
Clients must not decode a cursor, increment it, compare it with a prior cursor for ordering, or infer a page number. A server may sign a cursor, bind it to a catalog version, or map it to private state. That is the server's implementation detail.
The sample server deliberately returns "" after the first page. The client must send that exact value on the second request. Its trace is:
<first request with no cursor>
<second request with cursor "">Invalid cursors produce JSON-RPC invalid params, code -32602.
Completion Is an Authorization Surface
completion/complete provides suggestions for prompt arguments and resource-template arguments. It is useful for interactive forms, but it can leak names that ordinary list methods protect.
A completion request names a reference and the argument being completed:
{
"method": "completion/complete",
"params": {
"ref": {
"type": "ref/prompt",
"name": "deployment_review"
},
"argument": {
"name": "environment",
"value": "st"
}
}
}The result returns at most 100 values and may report total plus hasMore.
Apply the same authorization boundary used by the referenced prompt or resource. An analyst in the sample receives development and staging. Only an operator can receive production.
Production completion also needs:
- input validation;
- caller-aware filtering;
- request debouncing in the client;
- rate limiting in the server;
- bounded result counts;
- logs that do not expose sensitive suggestion values.
Completion is assistance, not discovery bypass.
Two Error Layers
Keep protocol errors separate from tool execution errors.
Use a JSON-RPC error when the MCP request cannot be dispatched correctly:
- unknown tool name;
- malformed request shape;
- missing request metadata;
- invalid cursor.
Use a complete tool result with isError: true when the invocation reached the tool and the tool reports an actionable failure:
- a report source is unavailable;
- a date is outside the supported range;
- a business rule rejects the requested operation.
Models can often repair a tool execution error. They cannot repair a server that violated its own output schema.
If the tool declares an output schema, model an actionable failure inside that
schema. The sample route_report failure returns its requested region with
accepted: false, alongside human-readable error text and isError: true.
Build It
code/main.py builds both sides of the boundary with the Python standard library.
The server implements:
- per-request MCP metadata validation;
server/discoverwith tools and completions capabilities;- deterministic
tools/listpagination; - four tool descriptors, including one that must be rejected;
- array structured output;
- every current tool content block type;
- a Streamable HTTP parity gate that decodes recognized parameter headers and
returns HTTP
400plus JSON-RPC-32020on mismatch; - authorized and rate-limited completion.
The client implements:
- descriptor admission;
- full-tree
x-mcp-headerplacement validation and sensitive-field policy; - exact plain-visible-ASCII or base64 UTF-8 value encoding;
- an opaque cursor loop that follows an empty string;
- argument and result validation;
- content-block validation;
- header audit events containing names but not values.
The deliberately unsafe descriptor is teaching data. It proves that one rejected tool does not prevent valid tools from loading.
Use It
From the repository root:
cd phases/13-tools-and-protocols/28-mcp-tool-contracts-and-content/code
python3 main.py
python3 -m unittest discover tests -vThe demo prints admitted tools, the rejected descriptor, both pagination requests, structured array content, content-block types, mirrored header names, whether the value required encoding, the HTTP parity status, and caller-filtered completion values.
Interactive Lab
Open code/main.py and locate TOOLS.
- Change
tag_catalog.outputSchema.typefromarraytoobject. - Run the demo. The client should reject the returned array.
- Restore the schema.
- Keep the first page's
nextCursoras"", then make the final page returnnextCursor: Noneinstead of omitting the field. - Run the tests and compare the cursor trace.
- Add
x-mcp-header: "Authorization"to a string property. - Confirm descriptor admission rejects it before invocation.
- Try
regionvalues containing Unicode, a newline, surrounding spaces, and the literal text=?base64?SGVsbG8=?=. Decode each emitted header and prove the original value survives exactly. - Move the annotation under
oneOf,items, or a$refdefinition. Confirm each descriptor is rejected even if that branch is never used by the demo. - Remove the recognized header or change its decoded value. Confirm the HTTP
boundary returns status
400and JSON-RPC code-32020.
The point is not to memorize a JSON shape. It is to watch each gate fail at the boundary that owns it.
Practice Lab
Extend the contract lab with a search_evidence tool.
Requirements:
- Its input schema accepts
query,limit, and a saferegionrouting field. - Its output schema is an array of objects with
uri,title, andscore. - The result includes compatibility text and a resource link per item.
- Arguments reject unknown properties.
limitis bounded by application validation.- A caller without access to one URI never sees that URI through completion or tool output.
- Tests include a nonconforming score, an invalid header annotation, and a two-page list.
- Header-value tests cover visible ASCII, Unicode, control characters, whitespace, sentinel-looking text, and both JavaScript-safe integer bounds.
- The HTTP fixture accepts case-insensitive header names but rejects missing
or mismatched recognized values with status
400and code-32020.
Shipped Artifact
outputs/skill-mcp-contract-reviewer.md is a flat, reusable review skill. Give it a tool descriptor, sample results, pagination behavior, and completion policy. It returns an admission decision, result-validation plan, header policy, and concrete failure tests.
Verify It
The lesson is complete when these statements are true:
tools/listreturns the same logical order on repeated calls.- The client performs a second request when
nextCursoris"". - The unsafe sensitive-header descriptor is excluded while other tools remain available.
- An array passes its array output schema.
- An object fails that same array schema.
- Error results cannot omit or violate a published output schema.
- Text, image, audio, resource link, and embedded resource blocks validate.
- Header audit events contain names and no values.
- Plain visible ASCII remains plain; Unicode, control, padded, empty, and sentinel-looking values round-trip through exact base64 UTF-8 encoding.
- Mirrored integers outside the JavaScript safe range are rejected.
- Annotations under
oneOf,items, nested objects,$refdefinitions, or output schemas are rejected during admission. - Case-insensitive recognized header names pass only when the decoded value
exactly matches the body; missing or mismatched copies produce HTTP
400and JSON-RPC-32020. - Analyst completion never returns
production. - A tool failure uses
isError: true; a malformed protocol call uses JSON-RPCerror.
Production Failure Modes
| Failure | What the learner sees | Correct response |
|---|---|---|
| Client assumes object output | Valid arrays fail or are silently wrapped | Validate against the published schema without object-only types |
| Empty cursor treated as false | Final pages disappear | Continue whenever nextCursor is present and non-null |
| Sensitive value mirrored | Secret appears in proxy, WAF, or trace data | Reject the descriptor and keep secrets in protected request data |
| Raw Unicode or whitespace mirrored | Gateway and origin disagree or the value is normalized | Use exact base64 UTF-8 sentinel encoding and compare after decoding |
| Annotation hidden in a schema branch | A client misses routing metadata during admission | Traverse the entire schema tree and allow only direct top-level properties |
| Large integer mirrored | JavaScript intermediary rounds the routing value | Reject values outside the JavaScript safe integer range |
| Header and body disagree | Gateway routes one target while the origin executes another | Reject before dispatch with HTTP 400 and JSON-RPC -32020 |
| Output schema ignored | Downstream code consumes corrupt structure | Validate before model or application use |
| Resource link trusted automatically | Caller follows an unauthorized URI | Reauthorize every resource read |
| Completion shares global suggestions | Hidden tenant names leak | Filter by caller, reference, and authorization |
| Tool annotations treated as policy | Destructive operation bypasses confirmation | Enforce authorization and approval outside annotations |
| One malformed tool breaks discovery | Entire server becomes unavailable | Reject the bad descriptor and admit valid tools independently |
Capstone Connection
The Phase 13 capstone needs a gateway that can merge tools from several servers. This lesson provides its admission core.
Use the artifact to grade four pieces of capstone evidence:
- deterministic and complete paginated discovery;
- descriptor validation before model exposure;
- validated structured output plus bounded content blocks;
- completion and routing metadata that preserve authorization boundaries.
Do not claim gateway compatibility from a successful tools/call alone. Capture the descriptor, page trace, admitted tool set, rejected tool set, and one validated result.
Key Terms
| Term | Meaning |
|---|---|
inputSchema | JSON Schema object defining accepted tool arguments |
outputSchema | Optional JSON Schema defining structuredContent |
structuredContent | Any JSON value produced by a tool result |
| Content block | Typed text, image, audio, resource link, or embedded resource |
x-mcp-header | Schema annotation that mirrors a primitive argument into Streamable HTTP metadata |
| Opaque cursor | Server-issued pagination token whose value the client does not interpret |
| Completion reference | Prompt name or resource URI/template whose argument is being completed |
| Admission | Client decision to expose or reject a discovered descriptor |
