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.
Host
Claude, ChatGPT, a code editor
The model and your conversation
- Client 1
- Client 2
- Client 3
- Streamable HTTPMCP TeamRemote: your business brain
- stdioFiles serverLocal: on your computer
- Streamable HTTPAny other serverRemote: a calendar, a CRM
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/. 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.
{"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.
{"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/, 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/ 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.
- Client to server: server/discoverOptional: which versions and features?
- Server to client: resultVersion
2026-07-28, capabilitytools - Client to server: tools/listVersion and capabilities ride along in
_meta - Server to client: resultSix tools, from
get_profiletofetch - Host: the model picks
get_fact - Client to server: tools/call
get_factwithquery: "sunday hours" - Server to client: resultText for the model, plus the same answer as JSON
- Client to server: initializeVersion
2025-11-25, capabilities, client info - Server to client: resultAgreed version, capabilities, server info, instructions
- Client to server: notifications/initializedA notification: no answer, just HTTP 202
- Client to server: tools/listVersion in the
MCP-Protocol-Versionheader - Server to client: resultThe same six tools
- Client to server: tools/call
get_fact, same arguments - 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": {}}}}{"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"}}}{"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.
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_factLeave out Mcp-Method and MCP Team's server answers 400 Bad Request:
{"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/stream the client opens. It replaces the GET stream andlisten 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/. The client gathers the answers and sends the original request again, with inputResponses and a new id.
{"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..."}}{"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/ 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/, 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.
{"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": {}}}}{"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",…}}}}contentis what the model reads: plain text, here with the fact's key and date.structuredContentis 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: trueand an explanation the model can act on. Send a 500-characterkeyand 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
| Tool | What it does | Annotations |
|---|---|---|
| get_profile | The approved brain as markdown, section by section | readOnlyHint: true |
| get_fact | One fact, by its key or a few words | readOnlyHint: true |
| list_upcoming | Upcoming dated items, soonest first | readOnlyHint: true |
| propose_change | Saves a suggestion the owner must approve | readOnlyHint: false, destructiveHint: false |
| search | Matching facts as id, title and url, the shape ChatGPT expects | readOnlyHint: true |
| fetch | One fact in full, by a search result's id | readOnlyHint: 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/ request naming what it wants, like toolsListChanged. The server confirms with notifications/, sends notifications/ whenever the list changes, and the client calls tools/ 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 }}}{"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
The client calls the server with no token. It gets
401and aWWW-Authenticateheader 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.That document names the authorization server. The client reads the authorization server's metadata (RFC 8414 or OpenID Connect Discovery).
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.
The user signs in and approves in a browser. The client uses PKCE with S256, names this exact server in a
resourceparameter (RFC 8707) and checks theissvalue when the reply includes one (RFC 9207).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.The server checks that the token was issued for it, and rejects any other. It never passes the token on to other APIs.
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.
9MCP Team's connector link, and why it isn't OAuth
MCP Team doesn't use the OAuth flow. Each business gets a private link, and the link is the key.
https:/ / mcp.team/ mcp/ <secret: 43 characters>- The secret is 32 random bytes, written as 43 URL-safe characters.
- The owner sees it once. The database keeps only its SHA-256 hash, plus the last four characters so the owner can recognize it.
- On each request the server hashes the secret in the path and looks up the business with that hash. A wrong, replaced or malformed secret always gets the same answer:
404 Not found. MCP Team's own code never writes the secret to its logs. The hosting platform's request logs still record the full URL, as with any secret carried in a link. - Each request builds a fresh MCP server for that one business. Nothing is shared between businesses.
- Making a new link turns off the old one at once, because only the new hash is stored.
- The link reads the facts the owner approved, except those marked private, and can file suggestions. It can't change a fact: the owner reviews each suggestion in the dashboard.
Why not OAuth
An owner pastes one link into Claude or ChatGPT, and it works. There's no account to create inside the chat app and no consent screen. The spec allows this: authorization is optional, and a client and server may agree on their own scheme.
The trade-off
The link is a bearer secret. Whoever has it can read those facts and file suggestions until the owner makes a new one. It carries no identity, no scopes and no expiry, so treat it like a password.
| Aspect | OAuth 2.1, per the spec | MCP Team's link |
|---|---|---|
| Where the credential goes | OAuth 2.1, per the spec: An Authorization: Bearer header, on every request | MCP Team's link: In the URL path |
| Who it stands for | OAuth 2.1, per the spec: A user who signed in and approved | MCP Team's link: Whoever has the link |
| What it allows | OAuth 2.1, per the spec: The scopes granted | MCP Team's link: Fixed: read approved facts, file suggestions |
| How long it lasts | OAuth 2.1, per the spec: Short-lived tokens, as the spec recommends | MCP Team's link: Until the owner makes a new link |
| Setup | OAuth 2.1, per the spec: Sign in and approve in a browser | MCP Team's link: Paste one link |
Want a link for your own business? Start with your website below. ↓
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.
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.
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.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.
Read annotations as hints, not promises
readOnlyHint,destructiveHint,idempotentHintandopenWorldHintdescribe 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_changesays it writes but destroys nothing. All six say they stay inside this business's brain.Check every input
Servers must validate tool inputs, control access and rate-limit calls. Over HTTP, they must check the
Originheader.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é.
- Specification overviewspecification/
2026-07-28 - Key changes since 2025-11-25specification/
2026-07-28/ changelog - Deprecated featuresspecification/
2026-07-28/ deprecated - Architecturespecification/
2026-07-28/ architecture - Base protocol: messages, statelessness, _metaspecification/
2026-07-28/ basic - Versioning and compatibilityspecification/
2026-07-28/ basic/ versioning - Discovery: server/discoverspecification/
2026-07-28/ server/ discover - Lifecycle and initialize (2025-11-25)specification/
2025-11-25/ basic/ lifecycle - Transport: stdiospecification/
2026-07-28/ basic/ transports/ stdio - Transport: Streamable HTTPspecification/
2026-07-28/ basic/ transports/ streamable-http - Multi round-trip requestsspecification/
2026-07-28/ basic/ patterns/ mrtr - Subscriptionsspecification/
2026-07-28/ basic/ patterns/ subscriptions - Toolsspecification/
2026-07-28/ server/ tools - Resourcesspecification/
2026-07-28/ server/ resources - Promptsspecification/
2026-07-28/ server/ prompts - Elicitationspecification/
2026-07-28/ client/ elicitation - Samplingspecification/
2026-07-28/ client/ sampling - Rootsspecification/
2026-07-28/ client/ roots - Cachingspecification/
2026-07-28/ server/ utilities/ caching - Authorizationspecification/
2026-07-28/ basic/ authorization - Authorization security considerationsspecification/
2026-07-28/ basic/ authorization/ security-considerations - Schema: ToolAnnotationsspecification/
2026-07-28/ schema#toolannotations - Docs: architecture, data and transport layersdocs/
2026-07-28/ learn/ architecture - Docs: security best practicesdocs/
2026-07-28/ tutorials/ security/ security_best_practices - Docs: local server security, prompt injection and tool poisoningdocs/
2026-07-28/ tutorials/ security/ local-server-security - Docs: client best practices, tool results as untrusted inputdocs/
2026-07-28/ develop/ clients/ client-best-practices
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 →