How Does MCP Work? The Protocol Step by Step (2026)
How does MCP work? A step-by-step walkthrough of the 2026-07-28 spec: discovery, tools/list, tools/call, elicitation, notifications and auth, with real JSON.
MCP works by having an AI application (the host) run one client per connected server, and having that client exchange JSON-RPC 2.0 messages with the server: it lists the server's tools, the model picks one, and the client sends tools/call and hands the result back to the model. Since the current spec revision, 2026-07-28, MCP is stateless. There is no handshake. Every request carries its own protocol version and client capabilities in _meta, so any server instance can answer any request.
This page follows one tool call over the wire, message by message. If you want the bigger picture first, read What Is MCP?. For the server side, see What Is an MCP Server?.
The cast: host, client, server
- Host: the app the user talks to, such as Claude Code, Cursor, VS Code or ChatGPT. It owns the conversation, the model and the consent prompts.
- Client: a connector the host creates for one server. Per the architecture spec, each client talks to exactly one server. A host with five servers runs five clients. (More in our MCP client guide.)
- Server: a program that exposes tools, resources and prompts. It can be a local process such as the Filesystem server, or a remote service such as the hosted Sentry MCP server.
The spec states a key design rule: servers never see the whole conversation, and they can't "see into" other servers. The host decides what each server receives.
Step 0: the transport comes up
Before any MCP message moves, the client needs a channel. The spec defines two standard transports:
| stdio | Streamable HTTP | |
|---|---|---|
| Where the server runs | A subprocess the client launches | Anywhere, behind one URL such as https://example.com/mcp |
| Framing | Newline-delimited JSON-RPC on stdin/stdout | One HTTP POST per message. The reply is JSON or a request-scoped SSE stream |
| Credentials | From the environment | OAuth 2.1 bearer tokens (see Step 7) |
| Cancel a request | Send notifications/cancelled | Close that request's response stream |
The old HTTP+SSE transport (2024-11-05) is formally deprecated. Our SSE vs Streamable HTTP post covers the migration.
Step 1 (optional): server/discover
The client can ask the server what it supports before doing anything else. Servers must implement server/discover, but clients don't have to call it. This exchange comes from the official architecture guide:
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "example-client",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": { "elicitation": {} }
}
}
}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": { "listChanged": true }, "resources": {} },
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "example-server",
"version": "1.0.0"
}
},
"ttlMs": 3600000,
"cacheScope": "public"
}
}
That _meta block is the core of the stateless design. Every request includes:
io.modelcontextprotocol/protocolVersion(required): the revision this request speaks.io.modelcontextprotocol/clientCapabilities(required): what the client can do for this request, for exampleelicitation.io.modelcontextprotocol/clientInfo(recommended): the client's name and version.
If the server doesn't speak the requested version, it returns UnsupportedProtocolVersionError (code -32022) with a supported list, and the client retries with a version from that list. A request that is missing a required field gets -32602 (Invalid params).
Step 2: list the tools
Next the client asks for the server's tool catalog:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": { "_meta": { "...": "same three fields as above" } }
}
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [
{
"name": "weather_current",
"title": "Weather Information",
"description": "Get current weather information for any location worldwide",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name, address, or coordinates"
},
"units": {
"type": "string",
"enum": ["metric", "imperial", "kelvin"],
"default": "metric"
}
},
"required": ["location"]
}
}
],
"ttlMs": 300000,
"cacheScope": "public"
}
}
Three details are new in 2026-07-28:
ttlMsandcacheScopeare now required on list results. Here the client may cache the list for five minutes.- The list must not vary per connection. It may vary by the caller's authorization, for example tools filtered by OAuth scope.
- Servers should return tools in a deterministic order, which keeps the model's prompt cache warm.
Large lists are paginated with a cursor. Hosts that combine many servers often load tools lazily; MCP tool discovery covers that pattern.
Step 3: the model picks a tool
This step isn't MCP at all. The host converts each tool's name, description and inputSchema into its model's function-calling format and sends them to the model with the user's message. When the model replies with a tool call such as weather_current with location: "San Francisco", the host should show the user what is about to run. The spec says there should always be a human who can deny a tool invocation.
Step 4: tools/call
The client sends the call. On Streamable HTTP, the transport also copies key body fields into headers so gateways can route the request without parsing JSON. Mcp-Method is required on every request, and Mcp-Name is required on tools/call, resources/read and prompts/get. Here is the example from the Streamable HTTP spec:
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Seattle, WA" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
If the MCP-Protocol-Version header doesn't match the body's _meta, the server rejects the request with 400 and a HeaderMismatch error (-32020). A successful reply looks like this:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "Current weather in Seattle, WA: 54°F, light rain..."
}
]
}
}
Every result now carries resultType. "complete" means this is the final answer. A tool that fails at the application level still returns a result, with isError: true, so the model can read the error and try again. Only protocol-level failures, such as an unknown tool, come back as JSON-RPC errors. The host adds content (or structuredContent when the tool declares an outputSchema) to the conversation, and the model writes its answer.
Step 5: when the server needs more input (MRTR)
Sometimes a tool can't finish without the user, for example to confirm a purchase or pick an account. Before 2026-07-28, the server would send its own JSON-RPC request back to the client. Now servers never initiate requests. Instead they use Multi Round-Trip Requests:
- The client sends
tools/call(id 1). - The server replies with
resultType: "input_required". The reply carriesinputRequests, for example anelicitation/createform, and an opaquerequestState. - The client shows the form to the user.
- The client retries the original call as a new request (id 2). It passes
params.inputResponses(the user's answer under the same key) and echoesparams.requestStateunchanged. - The server rebuilds its state from that retry and returns
resultType: "complete".
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"github_login": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please provide your GitHub username",
"requestedSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
},
"requestState": "AEAD-protected blob"
}
}
Because the server keeps no state between the two calls, it doesn't matter which server instance behind a load balancer handles the retry. The spec requires servers to treat requestState as attacker-controlled input and to protect its integrity, for example with HMAC or AEAD. Elicitation comes in two modes: form for ordinary data, and URL for anything sensitive, such as credentials, which must never pass through the client.
The other two client features, sampling and roots, can also ride MRTR. However, both are deprecated in 2026-07-28, along with protocol logging. New servers should call an LLM API directly and take paths as tool arguments.
Step 6: notifications
MCP has two kinds of notifications, and they travel on different paths:
- Request-scoped. Add
progressTokento a request's_meta, and the server may streamnotifications/progressmessages (progress,total,message) on that request's own response stream before the final result. - Change notifications. To hear about tool, prompt or resource changes, the client opens a long-lived
subscriptions/listenrequest and opts in to specific types. This replaced bothresources/subscribeand the old HTTP GET stream.
{
"jsonrpc": "2.0",
"id": 1,
"method": "subscriptions/listen",
"params": {
"_meta": { "...": "standard fields" },
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
The server first sends notifications/subscriptions/acknowledged, listing what it agreed to send. After that, notifications/tools/list_changed or notifications/resources/updated messages arrive, each tagged with io.modelcontextprotocol/subscriptionId. When the client receives a list_changed, it calls tools/list again.
To cancel a request, a stdio client sends notifications/cancelled with the request ID. On Streamable HTTP, the client closes that request's stream. Implementations should set timeouts on every request.
Step 7: authorization
Auth sits at the transport layer, outside the JSON-RPC messages. The authorization spec says:
- stdio servers should not use the OAuth flow. They read credentials from the environment, typically an
envblock in the client's config. - HTTP servers act as OAuth 2.1 resource servers. An unauthenticated request gets
401with aWWW-Authenticateheader that points to the server's protected-resource metadata. From there, the client discovers the authorization server, registers (Client ID Metadata Documents are now preferred, and Dynamic Client Registration is deprecated), runs PKCE, and sendsAuthorization: Beareron every request.
The full flow, with a working example, is in our MCP authentication guide.
What about initialize?
Most explanations online, and many servers still running today, describe the legacy flow from 2025-11-25 and earlier. In that flow, the client opens with an initialize request that carries protocolVersion, capabilities and clientInfo, the server replies with its own capabilities, and the client sends notifications/initialized. After that, the connection is a session: on HTTP, the server issues an Mcp-Session-Id header that later requests must echo. Revision 2026-07-28 removed the handshake, the session ID, ping and stream resumability.
The two eras interoperate through fallback rules in the versioning spec. On stdio, a client that supports both eras probes with server/discover and falls back to initialize if the reply isn't a modern response. On HTTP, it sends a modern request and falls back if it gets a 400 without a modern error body.
SDKs handle this for you, but check their defaults. In the TypeScript SDK v2, new Client(...) still performs the legacy handshake unless you pass versionNegotiation: { mode: 'auto' }. With that option, it probes with server/discover and falls back automatically. On the server side, the SDK's serveStdio helper answers both eras (protocol versions guide).
Watch it happen yourself
Reading JSON is one thing. Watching it live is better. Start the MCP Inspector against any server, for example npx @modelcontextprotocol/inspector plus the GitHub MCP server, and you can watch every tools/list and tools/call exchange. To build a server of your own, start with Build an MCP Server.
Next steps
You now know the full round trip: discover, list, call, input-required retry, notifications, auth. To see it in action, browse the MCP server directory and connect a server to your client.
Frequently asked questions
How does MCP work in simple terms?
- An AI app runs one MCP client per server. The client asks the server which tools it has, the model chooses one, and the client sends a tools/call request and passes the result back to the model. All messages are JSON-RPC 2.0.
Does MCP still use an initialize handshake?
- Not in the current 2026-07-28 revision. Every request carries its protocol version and client capabilities in _meta, and clients may call server/discover first. Servers on 2025-11-25 and earlier still use initialize, so SDKs fall back to it when needed.
What transport does MCP use?
- There are two standard transports. Local servers use stdio, with newline-delimited JSON-RPC over a subprocess's stdin and stdout. Remote servers use Streamable HTTP: one POST per message, answered with JSON or a request-scoped SSE stream. The older HTTP+SSE transport is deprecated.
How does an MCP server ask the user for input?
- Through elicitation, delivered by the Multi Round-Trip Requests pattern. The server answers a tools/call with an input_required result describing what it needs. The client collects the answer and retries the original request with inputResponses and the unchanged requestState.