technique
Anatomy of an MCP request
Host, client, and server roles, and what every MCP request and result carries in the stateless 2026-07-28 protocol: method, params, _meta, and resultType.
Before this
This page assumes you are comfortable with:
- prerequisiteJSON-RPCThe request, response, notification, and error shapes MCP borrows: ids, methods, params, results, and error codes.
- prerequisiteHow language models use toolsWhat actually happens when a chat model 'calls a tool': tool descriptions in the context, structured output, and the host doing the real work.
Why you need this
Every other page in this cluster, from designing a tool to deploying a server, ends up as messages on a wire. When a host cannot see your tool, or a call fails with a code you do not recognize, the fastest way to find out why is to read the actual request and result. This page teaches you to read them field by field, against the 2026-07-28 revision of the Model Context Protocol (MCP).
The idea
Why MCP exists
A language model can only write text; the app around it has to run real code (see How language models use tools). Before MCP, every app connected every tool its own way. With apps and tools that is up to separate integrations: 5 assistants and 20 tools is 100 pieces of glue code. With one shared protocol, each app implements it once and each tool implements it once: , so 25.
Roles
| Role | What it is |
|---|---|
| host | The app the person uses: a chat app, an IDE, an agent. It holds the model and decides what the model sees. |
| client | The host's connection to one server. A host with three servers runs three clients. |
| server | A program that offers tools (functions the model can ask to run), resources (data the host can read by URI), and prompts (templates a person can pick). |
| the model | The language model inside the host. It never talks to a server directly. |
The specification's Architecture section adds one design rule worth remembering: a server sees only the requests sent to it, never the whole conversation and never other servers.
Every message is JSON-RPC
MCP messages are JSON-RPC 2.0 requests, responses, and notifications, sent one at a time. The client sends requests; the server answers each one. A request's id is a string or an integer, never null.
The stateless model
The 2026-07-28 revision is stateless: everything the server needs to handle a request is inside that request. There is no opening handshake and no session. Two requests on the same connection are unrelated unless they say otherwise. That is why any copy of a server behind a load balancer can answer any request.
So every request repeats three facts in its params._meta object:
_meta key |
Required | Meaning |
|---|---|---|
io.modelcontextprotocol/protocolVersion |
yes | The protocol revision this request is written in, such as "2026-07-28". |
io.modelcontextprotocol/clientCapabilities |
yes | Optional features this client supports for this request, such as answering a form. {} means none. |
io.modelcontextprotocol/clientInfo |
no, but sent by default | The client's name and version, for logs and display. |
A request missing either required key is rejected as invalid params (-32602). If handling a request needs a capability the client did not declare, the server answers with error -32021 and lists what was missing.
Every result carries:
| Result field | Meaning |
|---|---|
resultType |
"complete" when the result is final. "input_required" when the server needs more input first (for example a confirmation), and the client must retry with the answers. A missing resultType, from an older server, means "complete". |
_meta with io.modelcontextprotocol/serverInfo |
The server's name and version. Self-reported, so use it for logs, not for trust. |
server/discover
server/discover is the one request every 2026-07-28 server must implement. It returns the protocol versions the server supports, its capabilities (does it have tools, resources, prompts), its identity, and optional instructions for the model. A client may call it first, but does not have to: it can send any request directly and handle a version error if one comes back. On stdio it doubles as a probe for older servers, covered in Version negotiation.
The method families
| Method | Purpose |
|---|---|
server/discover |
Versions, capabilities, identity. |
tools/list, tools/call |
List the tools; run one. |
resources/list, resources/templates/list, resources/read |
List data the server exposes; read one item by URI. |
prompts/list, prompts/get |
List prompt templates; fill one in. |
subscriptions/listen |
Open a long-lived stream of change notifications, such as "the tool list changed". |
completion/complete |
Suggest values for a prompt or resource argument as a person types. |
The list results (server/discover, tools/list, prompts/list, resources/list, resources/templates/list) and resources/read also carry caching hints: ttlMs, how many milliseconds the client may treat the result as fresh, and cacheScope, either "public" (the same for every user, safe to share) or "private" (only reuse for the same credentials).
Roots, Sampling, and Logging, which older revisions offered, are deprecated in 2026-07-28. Their replacements: pass directories as tool arguments or configuration, call a model provider directly, and log to stderr or a tracing system.
Two kinds of failure
| Kind | Shape | When |
|---|---|---|
| Protocol error | A JSON-RPC error with code and message |
The request itself is wrong: bad JSON, unknown method, missing _meta, unsupported version. |
| Tool execution error | A normal result with "isError": true and text in content |
The tool ran (or tried to) and failed in a way the model can read and fix, such as a bad argument value. |
The distinction matters because a host passes tool execution errors back to the model so it can try again, while protocol errors usually go to a log.
Worked example
Here is a small server built with the official Python SDK. One tool, a version number, and a caching hint telling clients they may reuse its tool list for five minutes.
from typing import Literal
from mcp.server import MCPServer
from mcp.server.caching import CacheHint
server = MCPServer(
"forecast",
version="1.0.0",
cache_hints={"tools/list": CacheHint(ttl_ms=300_000, scope="public")},
)
@server.tool()
def get_forecast(city: str, days: int, units: Literal["metric", "imperial"] = "metric") -> str:
"""Daily weather forecast for a city, up to 7 days ahead."""
return f"{city}: sunny for {days} days ({units})"
if __name__ == "__main__":
server.run()
Note the naming: in Python code the SDK uses snake_case (ttl_ms, input_schema, result_type), while on the wire the same fields are camelCase (ttlMs, inputSchema, resultType). This page shows the wire.
To see the raw messages, skip the SDK's client and play the client by hand. This script launches the server as a child process, writes one tools/list request to its standard input as a single line, and prints the line that comes back.
import json
import subprocess
import sys
META = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "hand-client", "version": "0.1.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
request = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": META}}
server = subprocess.Popen([sys.executable, "forecast_server.py"], text=True,
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
server.stdin.write(json.dumps(request) + "\n") # one message per line
server.stdin.flush()
reply = json.loads(server.stdout.readline())
print(json.dumps(reply, indent=2))
server.stdin.close()
server.wait()
The request it sends, pretty-printed:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "hand-client", "version": "0.1.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
Run python wire_list.py (with mcp 2.2.0 installed and both files in one folder). It prints:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"cacheScope": "public",
"resultType": "complete",
"tools": [
{
"description": "Daily weather forecast for a city, up to 7 days ahead.",
"inputSchema": {
"type": "object",
"properties": {
"city": {
"title": "City",
"type": "string"
},
"days": {
"title": "Days",
"type": "integer"
},
"units": {
"default": "metric",
"enum": [
"metric",
"imperial"
],
"title": "Units",
"type": "string"
}
},
"required": [
"city",
"days"
],
"title": "get_forecastArguments"
},
"name": "get_forecast",
"outputSchema": {
"properties": {
"result": {
"title": "Result",
"type": "string"
}
},
"required": [
"result"
],
"title": "get_forecastOutput",
"type": "object"
}
}
],
"ttlMs": 300000,
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "forecast",
"version": "1.0.0"
}
}
}
}
Field by field:
| Field | Where it came from | What a client does with it |
|---|---|---|
id: 1 |
Copied from the request | Matches the reply to the request. |
resultType: "complete" |
Every result has one | Treat the result as final. |
tools[0].name |
The function name | The identifier the model will put in its call. |
tools[0].description |
The docstring | Shown to the model; this is how it decides when to call the tool. |
inputSchema.properties |
The type hints | str became "string", int became "integer", Literal[...] became an enum. |
inputSchema.required |
Parameters without defaults | units has a default, so it is optional. |
outputSchema |
The -> str return hint |
A bare str is wrapped in an object with one result field. |
ttlMs: 300000 |
The CacheHint |
Fresh for 300,000 ms, which is 5 minutes. Without the hint this SDK sends 0. |
cacheScope: "public" |
The CacheHint |
Safe to share across users. Without the hint this SDK sends "private". |
_meta serverInfo |
MCPServer("forecast", version=...) |
Logs and display. |
No nextCursor appears, which means this is the whole list; a long list would be split into pages, each pointing to the next.
The same server answering four mistakes, each sent as its own request:
| Request | Reply |
|---|---|
tools/list with "params": {} (no _meta) |
Protocol error -32602, naming the two missing _meta keys. |
Method tools/frobnicate |
Protocol error -32601, "Method not found". |
tools/call with "days": "two" |
A result with "isError": true and a validation message the model can act on. |
tools/call for a tool named get_weather |
A result with "isError": true and "Unknown tool: get_weather". The specification files an unknown tool under protocol errors (-32602); this SDK reports it as a tool error instead, so a client should handle both. |
The demo below steps through a whole conversation with a different server (a notes store): server/discover, tools/list, a tools/call that comes back input_required, the retry with the answer, and the final result. Switch the transport to see the same JSON as a line on stdin or as an HTTP POST with headers.
In a server's life
This is stage 1, speak the protocol. Everything in later stages is built from these messages: tool design (stage 2) is what goes inside tools/list, transports (stage 3) are how these lines travel, authorization (stage 4) adds a header in front of them, and operations (stage 6) is mostly logging the method, resultType, error code, and clientInfo of each one.
Common mistakes
- Leaving out
_metabecause an older server did not need it. Symptom: every request fails with -32602 before any of your code runs. - Treating a connection as a session. A server that stores "the current user" in a variable on the first request will mix up users once requests are interleaved or spread across instances. State that spans requests needs an explicit identifier passed on each request.
- Printing to stdout in a stdio server. A debug
printlands in the message stream. Symptom: the client logs a parse error for that line; the Python SDK's client skips it and carries on, but a stricter client may drop the connection, and a print that lands mid-message corrupts a real reply. - Ignoring
resultType. A client that treatsinput_requiredas final shows the person an empty answer. Check it on every result. - Treating tool errors as protocol errors, or the reverse. Raising a protocol error for a bad argument hides the message from the model, which then cannot fix its call. Symptom: the model repeats the same broken call.
- Trusting
serverInfo. Any server can claim any name. Never use it for a security decision.
Cost
Statelessness has a size cost: every request repeats its _meta, usually a couple of hundred bytes, which is negligible next to tool definitions and results. It removes a round trip, since there is no handshake before the first real call, and it removes per-connection memory on the server. The larger cost is tokens: the tools/list result above is the text the host turns into the model's tool definitions, re-read on every turn, which is why ttlMs matters. A client that honors a five-minute TTL re-fetches the list at most about every five minutes instead of on every turn, and a deterministic tool order keeps the model provider's prompt cache effective.
Going further
- Version negotiation, for what happens when the client and server name different revisions.
- Designing tools, for what to put in the
tools/listresult. - Multi round-trip requests, for the
input_requiredresult in the demo. - Transports, for how these lines travel over stdio and Streamable HTTP.
- The Base Protocol, Architecture, and Discovery sections of the 2026-07-28 specification.
Leads to
- techniqueDesigning toolsNaming, describing, and shaping a tool so a model picks it correctly and calls it with valid arguments: input and output schemas, structured content, and errors.
- techniqueResources and promptsThe two primitives besides tools: resources expose data the host can read by URI, and prompts are reusable templates a person picks, plus caching and completion for both.
- techniqueTransports: stdio and Streamable HTTPThe two ways messages travel: a local child process over stdin and stdout, or a remote endpoint over HTTP POST with optional streamed responses, plus long-lived subscriptions.
- techniqueVersion negotiationHow a client and server that speak different protocol revisions find one they share, per request, and how a 2026-era server still serves older handshake-based clients.