Skip to content

How MCP works, in depth

For the technical folks: the parts of the Model Context Protocol, the messages they exchange and how a connection stays safe. Checked against the current spec, revision 2026-07-28, with MCP Team's own server as the worked example.

Spec revision
2026-07-28, the current one
Messages
JSON-RPC 2.0
Transports
stdio for local servers, Streamable HTTP for remote ones
Servers offer
Tools, resources and prompts
Clients offer
Elicitation. Sampling and roots still work, but are deprecated
Access over HTTP
OAuth 2.1, when the server needs it

1Three parts: host, client, server

The host is the AI app you use: Claude, ChatGPT, a code editor. It works with the model, keeps the conversation and decides what needs your approval.

For each server it connects to, the host creates a client. One client, one server, so every connection stays separate.

A server offers data and actions. It can run on your computer or on the internet. By design, it sees only the requests sent to it: not the whole conversation, and not the other servers.

One client per server connection. The host keeps the conversation; each server sees only what is sent to it.

Two layers

MCP has two layers. The data layer is what gets said: JSON-RPC messages for discovery, tools, resources, prompts and notifications. The transport layer is how it travels: opening the connection, framing each message and handling authorization.

2Every message is JSON-RPC 2.0

MCP doesn't invent a wire format. Every message is a JSON-RPC 2.0 object, of one of three kinds.

Request

Asks for something. It has an id and a method, such as tools/list. In MCP the id can never be null.

tools/listclient → server
{"jsonrpc": "2.0","id": 2,"method": "tools/list","params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28","io.modelcontextprotocol/clientInfo": {"name": "ExampleClient","version": "1.0.0"},"io.modelcontextprotocol/clientCapabilities": {}}}}

Response

Answers one request, with the same id, and holds a result or an error. Since 2026-07-28 every result names its resultType: complete, or input_required when the server needs more before it can finish.

Responseserver → client
{"jsonrpc": "2.0","id": 2,"result": {"tools": […],"resultType": "complete",…}}

Notification

A one-way message with no id. Nobody answers it. Progress updates and change alerts travel this way.

Notificationexample from the spec
{"jsonrpc": "2.0","method": "notifications/tools/list_changed"}

In the current revision only the client sends requests. The server answers each one, and may send notifications about the request it's working on. When it needs something from the user or the client, it asks inside its answer instead of sending a request of its own.

Errors use JSON-RPC's standard codes, such as -32601 (method not found) and -32602 (invalid params). MCP adds three: -32020 header mismatch, -32021 missing client capability and -32022 unsupported protocol version.

3No more handshake: each request stands alone

Up to revision 2025-11-25, every connection opened with a handshake. The client sent initialize with its protocol version and capabilities, the server answered with its own, the client confirmed with notifications/initialized, and only then did the work start.

Revision 2026-07-28 dropped the handshake and protocol sessions. Every request now carries the protocol version and the client's capabilities in _meta, and should name the client too. A server can't rely on anything an earlier request said, even on the same connection.

Every server must answer server/discover with its versions, capabilities and identity. Calling it first is optional: a client can just send its request, and if the version doesn't match, the server replies with error -32022 and the versions it supports.

MCP Team's server speaks both. A 2026-07-28 request is served on its own, and an older client that opens with initialize gets the 2025-11-25 handshake. Both ladders below follow its real answers.

2026-07-28: each request stands alone
  1. Client to server: server/discoverOptional: which versions and features?
  2. Server to client: resultVersion 2026-07-28, capability tools
  3. Client to server: tools/listVersion and capabilities ride along in _meta
  4. Server to client: resultSix tools, from get_profile to fetch
  5. Host: the model picks get_fact
  6. Client to server: tools/callget_fact with query: "sunday hours"
  7. Server to client: resultText for the model, plus the same answer as JSON
2025-11-25 and earlier: handshake first
  1. Client to server: initializeVersion 2025-11-25, capabilities, client info
  2. Server to client: resultAgreed version, capabilities, server info, instructions
  3. Client to server: notifications/initializedA notification: no answer, just HTTP 202
  4. Client to server: tools/listVersion in the MCP-Protocol-Version header
  5. Server to client: resultThe same six tools
  6. Client to server: tools/callget_fact, same arguments
  7. Server to client: resultThe same answer
server/discoverclient → server
{"jsonrpc": "2.0","id": 1,"method": "server/discover","params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28","io.modelcontextprotocol/clientInfo": {"name": "ExampleClient","version": "1.0.0"},"io.modelcontextprotocol/clientCapabilities": {}}}}
Responseserver → client · shortened with …
{"jsonrpc": "2.0","id": 1,"result": {"resultType": "complete","supportedVersions": ["2026-07-28"],"capabilities": { "tools": { "listChanged": true } },"instructions": "This connector is Example Café's business brain: facts the owner h…","ttlMs": 0,"cacheScope": "private","_meta": {"io.modelcontextprotocol/serverInfo": {"name": "mcp-team","version": "0.1.0",…}}}}

instructions is plain text the host can hand to the model. MCP Team uses it to say what the connector is and how to treat its facts.

Before 2026-07-28, the same information came from the handshake. MCP Team still answers it:

initializeclient → server
{"jsonrpc": "2.0","id": 1,"method": "initialize","params": {"protocolVersion": "2025-11-25","capabilities": {},"clientInfo": {"name": "ExampleClient","version": "1.0.0"}}}
Responseserver → client · shortened with …
{"jsonrpc": "2.0","id": 1,"result": {"protocolVersion": "2025-11-25","capabilities": { "tools": { "listChanged": true } },"serverInfo": {"name": "mcp-team","version": "0.1.0",…},"instructions": "This connector is Example Café's business brain: facts the owner h…"}}

4Two transports: stdio and Streamable HTTP

stdio, for local servers

The client starts the server as a subprocess on the same machine. Messages go over stdin and stdout, one JSON object per line. The server may log to stderr, never to stdout. There's no network and no OAuth: a local server reads its credentials from the environment.

Streamable HTTP, for remote servers

The server has one endpoint, and every message is its own HTTP POST. The answer is plain JSON, or an event stream (SSE) for that one request, which can carry progress notes before the result.

The client also copies key fields into headers, so gateways can route without reading the body: MCP-Protocol-Version on every request and, new in 2026-07-28, Mcp-Method and, for a tool call, Mcp-Name. If a header and the body disagree, the server must reject the request. Servers must also check the Origin header, to stop DNS rebinding.

A tool call to MCP Team, over HTTPclient → server
POST /mcp/<secret> HTTP/1.1Host: mcp.teamContent-Type: application/jsonAccept: application/json, text/event-streamMCP-Protocol-Version: 2026-07-28Mcp-Method: tools/callMcp-Name: get_fact

Leave out Mcp-Method and MCP Team's server answers 400 Bad Request:

ResponseHTTP 400
{"jsonrpc": "2.0","id": 3,"error": {"code": -32020,"message": "Bad Request: the request headers and body disagree: the body names method tools/call but the required Mcp-Method header is absent","data": {"mismatch": {"header": "(missing)","body": "the body names method tools/call but the required Mcp-Method header is absent"}}}}

Gone, or on the way out

  • The original HTTP+SSE transport from 2024-11-05: deprecated since 2025-03-26. Use Streamable HTTP.
  • Sessions (Mcp-Session-Id), the GET stream and resumable streams (Last-Event-ID): removed from Streamable HTTP in 2026-07-28.
  • Change alerts now come on a subscriptions/listen stream the client opens. It replaces the GET stream and resources/subscribe.

5What servers offer, and what clients offer back

A server can offer three kinds of things. What sets them apart is who decides when they're used.

  • Tools

    Who decides: the model

    Functions the model can call to look something up or take an action.

    tools/listtools/call

  • Resources

    Who decides: the app

    Data with a URI, like a file or a database schema, that the app attaches as context.

    resources/listresources/readresources/templates/list

  • Prompts

    Who decides: the user

    Templates the user picks, often as slash commands.

    prompts/listprompts/get

Clients can offer things back. The server asks for them in the middle of a request.

  • Elicitation

    The server asks the user for input. Form mode shows a short form described by a flat JSON Schema. URL mode sends the user to a web page instead, and is required for passwords, API keys and payment details, so they never pass through the client.

  • Sampling

    Deprecated. The server asks the client's model for a completion. New servers should call a model provider directly.

  • Roots

    Deprecated. The client tells the server which folders matter. New servers should take paths as tool arguments or settings.

Sampling, roots and server logging were deprecated in 2026-07-28. They keep working for at least twelve months.

How a server asks now

Servers no longer send requests of their own. A server answers with resultType: "input_required" and a set of inputRequests, for example an elicitation/create. The client gathers the answers and sends the original request again, with inputResponses and a new id.

The server's answerexample from the spec
{"jsonrpc": "2.0","id": 2,"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": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."}}
The client tries againexample from the spec
{"jsonrpc": "2.0","id": 3,"method": "tools/call","params": {"name": "get_weather","arguments": { "location": "New York" },"inputResponses": {"github_login": {"action": "accept","content": { "name": "octocat" }}},"requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."}}

MCP Team uses tools only. Its server/discover answer lists just tools, and it never asks the client for input.

6A tool up close: MCP Team's get_fact

A tool is a name, a description the model reads and a JSON Schema for its arguments. Here is a real entry from MCP Team's tools/list, for a sample café.

name
What the model calls. Unique within the server.
title
A display name for people.
description
What the model reads to decide when to use the tool.
inputSchema
JSON Schema for the arguments, version 2020-12 unless the schema says otherwise. MCP Team writes its schemas in zod, with length limits.
outputSchema
Optional: the shape of the JSON the tool returns. MCP Team doesn't declare one.
annotations
Hints about how the tool behaves. More in section 10.
Responsetools/list · one of six tools in full, the rest shortened with …
{"jsonrpc": "2.0","id": 2,"result": {"tools": [{ "name": "get_profile", … },{"name": "get_fact","title": "Example Café: look up one fact","description": "Looks up a single fact in Example Café's business brain (for example a price, an offer, a policy, a booking link or a contact detail) by its exact key or by a few words describing it. Returns the value with its label, section and when it was last updated, or says that it is not in the brain.","inputSchema": {"type": "object","$schema": "https://json-schema.org/draft/2020-12/schema","properties": {"key": {"description": "Exact fact key, e.g. prices.drop-in-class.","type": "string","maxLength": 120},"query": {"description": "A few words describing the fact, e.g. 'cancellation policy'.","type": "string","maxLength": 200}}},"annotations": {"readOnlyHint": true,"destructiveHint": false,"idempotentHint": true,"openWorldHint": false}},{ "name": "list_upcoming", … },{ "name": "propose_change", … },{ "name": "search", … },{ "name": "fetch", … }],"resultType": "complete","ttlMs": 0,"cacheScope": "private","_meta": {"io.modelcontextprotocol/serverInfo": {"name": "mcp-team","version": "0.1.0",…}}}}

The call

A customer asks the café's assistant: are you open Sunday morning? The model picks get_fact, and the client sends:

tools/callclient → server
{"jsonrpc": "2.0","id": 3,"method": "tools/call","params": {"name": "get_fact","arguments": { "query": "sunday hours" },"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28","io.modelcontextprotocol/clientInfo": {"name": "ExampleClient","version": "1.0.0"},"io.modelcontextprotocol/clientCapabilities": {}}}}
Responseserver → client · shortened with …
{"jsonrpc": "2.0","id": 3,"result": {"content": [{"type": "text","text": "Sunday hours (Schedule & dates): 7 am to 1 pm. Breakfast until noon.\nKey: schedule.sunday-hours\nLast updated: 2026-10-14"}],"structuredContent": {"found": true,"key": "schedule.sunday-hours","section": "schedule","label": "Sunday hours","value": "7 am to 1 pm. Breakfast until noon.","updated_at": "2026-10-14","event_start": null,"event_end": null},"resultType": "complete","_meta": {"io.modelcontextprotocol/serverInfo": {"name": "mcp-team","version": "0.1.0",…}}}}
  • content is what the model reads: plain text, here with the fact's key and date.
  • structuredContent is the same answer as JSON, for code that wants fields instead of prose.
  • When a tool can't do its job, it still returns a result, with isError: true and an explanation the model can act on. Send a 500-character key and MCP Team answers: Input validation error: … expected string to have <=120 characters.
  • A request the server can't run at all gets a JSON-RPC error instead. Ask MCP Team for a tool that doesn't exist and it returns -32602: Tool delete_everything not found.

All six tools

ToolWhat it doesAnnotations
get_profileThe approved brain as markdown, section by sectionreadOnlyHint: true
get_factOne fact, by its key or a few wordsreadOnlyHint: true
list_upcomingUpcoming dated items, soonest firstreadOnlyHint: true
propose_changeSaves a suggestion the owner must approvereadOnlyHint: false, destructiveHint: false
searchMatching facts as id, title and url, the shape ChatGPT expectsreadOnlyHint: true
fetchOne fact in full, by a search result's idreadOnlyHint: true

All six also say openWorldHint: false: they only touch this business's brain.

7When the tool list changes

A server that will announce changes to its tool list declares listChanged: true. In 2026-07-28, a client that wants to hear about changes opens a long-lived subscriptions/listen request naming what it wants, like toolsListChanged. The server confirms with notifications/subscriptions/acknowledged, sends notifications/tools/list_changed whenever the list changes, and the client calls tools/list again. Prompts and resources work the same way, and a client can also watch specific resources for updates.

MCP Team's server declares it and accepts the subscription, as below. Its six tools are the same on every request, so the stream has nothing to announce.

subscriptions/listenclient → server
{"jsonrpc": "2.0","id": 4,"method": "subscriptions/listen","params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28","io.modelcontextprotocol/clientInfo": {"name": "ExampleClient","version": "1.0.0"},"io.modelcontextprotocol/clientCapabilities": {}},"notifications": { "toolsListChanged": true }}}
First event on the streamserver → client
{"jsonrpc": "2.0","method": "notifications/subscriptions/acknowledged","params": {"notifications": { "toolsListChanged": true },"_meta": {"io.modelcontextprotocol/subscriptionId": 4}}}

List results also carry cache hints: ttlMs, how many milliseconds the result stays fresh, and cacheScope, public or private. MCP Team sends ttlMs: 0 and private: treat the list as stale right away, and don't share a cached copy between callers.

8Signing in to a remote server: OAuth 2.1

Authorization is optional in MCP. When an HTTP server needs it, the spec builds on OAuth 2.1. The MCP server acts as the resource server, and an authorization server, run by the same company or a different one, signs the user in and issues tokens.

Local stdio servers skip all of this and read credentials from the environment.

The flow, step by step

  1. The client calls the server with no token. It gets 401 and a WWW-Authenticate header that can point to the server's protected resource metadata (RFC 9728). If it doesn't, the client looks for that document at a well-known URL.

  2. That document names the authorization server. The client reads the authorization server's metadata (RFC 8414 or OpenID Connect Discovery).

  3. The client identifies itself: with an ID registered beforehand, or with a Client ID Metadata Document, an HTTPS URL that describes the client. Dynamic Client Registration still works but is deprecated.

  4. The user signs in and approves in a browser. The client uses PKCE with S256, names this exact server in a resource parameter (RFC 8707) and checks the iss value when the reply includes one (RFC 9207).

  5. The client trades the code for an access token and sends it on every request as Authorization: Bearer …, never in the URL's query string.

  6. The server checks that the token was issued for it, and rejects any other. It never passes the token on to other APIs.

Step 1, the challengeexample from the spec
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="files:read"

Least privilege

Clients ask for the smallest set of scopes. When an action needs more, the server answers 403 with insufficient_scope and the scopes that action needs, and the client asks the user to approve those on top of the ones it already has.

10Security that holds up

MCP can't enforce safety by itself. The spec leaves it to hosts, clients and servers. Five habits matter most; under each, how MCP Team applies it.

  1. Give the least access that works

    Ask for the smallest set of scopes, add more only when an action needs them, and keep each server to one job.

    In MCP Team: The link can read approved facts and file suggestions. Nothing else. Facts marked private never reach it.

  2. Keep a person in the loop for actions

    Hosts must get the user's consent before a tool runs, and there should always be a human who can say no. Clients should show a tool's inputs before sending them.

    In MCP Team: The one tool that writes, propose_change, only saves a suggestion. Nothing changes until the owner approves it, and new suggestions stop at 25 waiting.

  3. Treat what comes back as untrusted

    Tool descriptions and results land in the model's context, and text hidden in them can steer it. That's prompt injection; when it hides in a tool's description it's called tool poisoning. Clients should check results before the model sees them, and servers must clean what they return.

    In MCP Team: Facts come from websites and pasted text, so the server strips invisible characters (zero-width, direction overrides, Unicode tag characters), caps lengths and keeps a value from starting its own heading. Its instructions tell the model that fact values are data, not instructions.

  4. Read annotations as hints, not promises

    readOnlyHint, destructiveHint, idempotentHint and openWorldHint describe what a tool does. A client must not trust them from a server it doesn't trust.

    In MCP Team: Five tools say read-only. propose_change says it writes but destroys nothing. All six say they stay inside this business's brain.

  5. Check every input

    Servers must validate tool inputs, control access and rate-limit calls. Over HTTP, they must check the Origin header.

    In MCP Team: Arguments are checked against each tool's schema before the tool runs, and every request has to carry a valid secret.

11Sources

Checked against these pages of the specification and its docs on modelcontextprotocol.io, revision 2026-07-28 unless the link says otherwise. The MCP Team examples come from running its server code with a sample café.

See it with your own business

Paste your website and watch us draft your business memory. Free, no sign-up.

Want AI wired into your own tools? Talk to us →

We only read public pages. Already have a brain? Sign in.