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:

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 AA apps and TT tools that is up to A×TA \times T 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: A+TA + T, 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 _meta because 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 print lands 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 treats input_required as 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/list result.
  • Multi round-trip requests, for the input_required result 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

Back to Building and maintaining MCP servers