TL;DR. MCP is JSON-RPC 2.0 over one of two transports — stdio (local subprocess) or Streamable HTTP (remote, one endpoint, optional SSE) [3]. A server exposes three primitives — tools (model-controlled), resources (app-driven), prompts (user-controlled) — discovered via
*/listand invoked viatools/call/resources/read/prompts/get[5][6][7]. Every connection opens with aninitialize→initializeresult →notifications/initializedhandshake that negotiates a date-string protocol version and per-feature capabilities [4]. Current stable spec:2025-11-25[1]. The TS SDK [10] ⭐ 12.6k hides most of this — but the wire contract below is what leaks through when something breaks.
Architecture in one paragraph
MCP is host/client/server over JSON-RPC 2.0. A host (the LLM app) spawns one client per connection; each client talks to one server [1]. Connections are stateful and bidirectional: the server can call back into the client (sampling, elicitation, roots) during a request. Servers expose tools / resources / prompts; clients expose sampling / roots / elicitation [1]. The spec is a TypeScript schema (schema.ts) that is the source of truth; the JSON Schema is generated from it [9].
JSON-RPC message shapes
Three message types, nothing more. The ID rules are the part that bites SDK authors [9]:
| Message | Shape | Rules |
|---|---|---|
| Request | {jsonrpc, id, method, params?} |
id MUST be string/integer, MUST NOT be null, unique per session [9] |
| Result | {jsonrpc, id, result} |
same id as request; result any JSON object [9] |
| Error | {jsonrpc, id, error:{code, message, data?}} |
code MUST be integer; same id (except unreadable/malformed) [9] |
| Notification | {jsonrpc, method, params?} |
no id; receiver MUST NOT reply [9] |
JSON-RPC batching is gone. It was added in 2025-03-26 and removed in 2025-06-18; do not send or expect arrays of messages on the current spec [13]. (The transport doc’s batch language predates the removal.)
Two MCP-specific error codes worth knowing: -32002 resource-not-found [6]; the rest are standard JSON-RPC (-32602 invalid params, -32603 internal).
Transports
| Axis | stdio | Streamable HTTP |
|---|---|---|
| Topology | Client launches server as subprocess | Independent process, many clients |
| Framing | Newline-delimited JSON on stdin/stdout |
One HTTP endpoint, POST + GET |
| Logging | Free-form on stderr (any log level) |
Out of band |
| Streaming | n/a | Optional SSE (text/event-stream) for server→client |
| Auth | Credentials from env (not the OAuth framework) | OAuth 2.0 framework [9] |
| Use when | Local tools, CLIs, IDE plugins | Remote / multi-client / hosted |
| Caveat | Single-client; collapses under concurrency [15] | DNS-rebinding risk → validate Origin [3] |
stdio (prefer it when you can). Client launches the server as a subprocess; messages are JSON-RPC, newline-delimited, MUST NOT contain embedded newlines, and stdout MUST carry only valid MCP messages — stray console.log corrupts the stream [3]. Put all logging on stderr [8]. Clients SHOULD support stdio whenever possible [3].
Streamable HTTP (the remote standard). A single MCP endpoint (e.g. https://example.com/mcp) handling both POST and GET [3]:
- Client → server: every JSON-RPC message is a
POST. TheAcceptheader MUST list bothapplication/jsonandtext/event-stream[3]. - Response forking: if the POST body is only responses/notifications, the server returns
202 Accepted(no body). If it contains requests, the server returns eitherapplication/json(one object) ortext/event-stream(an SSE stream that eventually yields one response per request) — the client MUST handle both [3]. - Server → client without a request: client
GETs the endpoint to open an SSE stream; server returnstext/event-streamor405 Method Not Allowedif it offers none [3]. - Sessions: server MAY return
Mcp-Session-Idon theInitializeResult; if so the client MUST echo it on every later request. Missing it →400; terminated session →404(client then re-initializes); clientDELETEends a session [3]. - Resumability: SSE events carry an
id; client reconnects withLast-Event-IDto replay missed events on that stream [3].
SSE / legacy. The old two-endpoint HTTP+SSE transport (separate GET SSE channel + POST endpoint) from 2024-11-05 was replaced by Streamable HTTP in 2025-03-26 and is deprecated; SSE now lives inside Streamable HTTP as an optional streaming mode, not a separate transport [14]. For backward compat a client probes by POSTing initialize; a 4xx means fall back to the old GET-an-endpoint-event flow [3].
Protocol-version header (HTTP only). After init, the client MUST send MCP-Protocol-Version: <version> on every request. If the header is absent and the version can’t be inferred, servers default to 2025-03-26 for back-compat [4]. ⚠ A known TS-SDK quirk: if the header disagrees with the body’s protocolVersion on initialize, the body wins and no error is raised [13].
The lifecycle handshake
Three steps, always, before any feature call [4]:
// 1. client → server
{ "jsonrpc":"2.0", "id":1, "method":"initialize", "params":{
"protocolVersion":"2025-11-25",
"capabilities":{ "roots":{"listChanged":true}, "sampling":{}, "elicitation":{} },
"clientInfo":{ "name":"ExampleClient", "version":"1.0.0" } } }
// 2. server → client (result): echoes version, declares its capabilities + serverInfo,
// optional top-level "instructions" string the client can feed the model
{ "jsonrpc":"2.0", "id":1, "result":{
"protocolVersion":"2025-11-25",
"capabilities":{ "tools":{"listChanged":true}, "resources":{"subscribe":true,"listChanged":true}, "prompts":{"listChanged":true}, "logging":{} },
"serverInfo":{ "name":"ExampleServer", "version":"1.0.0" },
"instructions":"Optional instructions for the client" } }
// 3. client → server (notification, no id): handshake complete
{ "jsonrpc":"2.0", "method":"notifications/initialized" }
Rules that matter for a server author [4]:
- Before the client’s
initializedarrives, the server SHOULD send onlypingorlogging— no feature traffic. - Version negotiation: versions are date strings, not semver. Client sends its latest; if the server supports it, it MUST echo the same string. If not, it MUST reply with another version it supports (its latest). If the client doesn’t support that, it SHOULD disconnect. On an explicit error the server returns
-32602withdata.supportedlisting versions [4]. - Shutdown has no message — for stdio, close the child’s
stdin, thenSIGTERM/SIGKILL; for HTTP, close the connection(s) [4]. - Timeouts: senders SHOULD time out requests and emit a
CancelledNotification; progress notifications MAY reset the clock but a hard cap SHOULD remain [4].
Capability negotiation
You can only use a feature both sides declared at init. Declaring a capability is what enables its methods and notifications [4]:
| Side | Capability | Unlocks |
|---|---|---|
| Server | tools |
tools/list, tools/call; listChanged sub-cap [5] |
| Server | resources |
resources/list /read /templates/list; subscribe, listChanged sub-caps [6] |
| Server | prompts |
prompts/list, prompts/get; listChanged sub-cap [7] |
| Server | logging |
notifications/message log emission [4] |
| Server | completions |
argument autocompletion [4] |
| Server | tasks |
experimental durable/async requests (new in 2025-11-25) [8] |
| Client | roots |
server asks for filesystem/URI boundaries [4] |
| Client | sampling |
server requests an LLM completion through the client [4] |
| Client | elicitation |
server asks the user for structured input [4] |
Two sub-capability flags recur: listChanged (server will emit notifications/<x>/list_changed when its catalog changes) and, for resources only, subscribe (per-item change notifications) [4].
The three primitives
The whole mental model: who’s in control differs per primitive — that decides where your UX consent lives [5][6][7].
| Primitive | Control | List | Invoke | Item key fields | Change signals |
|---|---|---|---|---|---|
| Tools | model-controlled | tools/list |
tools/call |
name, inputSchema, outputSchema, annotations [5] |
notifications/tools/list_changed [5] |
| Resources | app-driven | resources/list (+ /templates/list) |
resources/read |
uri, name, mimeType [6] |
.../list_changed, .../updated (subscribe) [6] |
| Prompts | user-controlled | prompts/list |
prompts/get |
name, arguments[] [7] |
notifications/prompts/list_changed [7] |
Tools — model-controlled (the one you’ll build first)
tools/list (paginated via cursor/nextCursor) returns Tool objects: name (1–128 chars, [A-Za-z0-9_.-]), optional title/description/icons, inputSchema (JSON Schema object, never null; use {"type":"object","additionalProperties":false} for no-arg tools), optional outputSchema, annotations, and execution.taskSupport (forbidden/optional/required) [5].
tools/call returns content[] (text / image / audio / resource_link / embedded resource) plus isError [5]. If you set outputSchema, you MUST also return structuredContent conforming to it — and, for back-compat, SHOULD serialize that same JSON into a text block [5].
⚠ Error model is two-tier and easy to get wrong. Unknown tool / malformed request → JSON-RPC protocol error (-32602). API failure / input-validation / business error → a normal result with isError: true so the model sees it and can self-correct. Returning a validation failure as a protocol error denies the model its retry path [5]. The 2025-11-25 revision explicitly reclassified input-validation errors as tool errors for this reason [8].
Resources — app-driven
URI-identified context. resources/list and resources/read (returns contents[] with text or base64 blob); resources/templates/list exposes RFC 6570 uriTemplates for parameterized resources [6]. Standard URI schemes: https:// (only when the client can fetch directly), file://, git://, or custom (RFC 3986) [6]. With the subscribe sub-cap, resources/subscribe + notifications/resources/updated push per-resource change events; listChanged covers catalog changes [6].
Prompts — user-controlled
Reusable templated conversations, surfaced as user actions (often slash commands). prompts/list returns Prompt objects (name, arguments[]); prompts/get substitutes arguments and returns messages[] of {role, content} where content is text / image / audio / embedded resource [7]. Because prompts are user-invoked, the consent UX lives at selection, not at execution [7].
What changed recently (and what’s next)
Now (2025-11-25, current stable) vs 2025-06-18 [8]:
- Tasks (experimental) — turn any request into call-now/fetch-later with polling; states like
working/input_required/completed/failed/cancelled. Kills fake progress spinners for long jobs [12]. - Icons on tools/resources/prompts/implementations (SEP-973) —
srcmust behttps:/data:, treated as untrusted [9]. - JSON Schema 2020-12 is now the default dialect when
$schemais absent (SEP-1613) [8]. - OAuth hardening — OIDC discovery, incremental scope consent via
WWW-Authenticate, and Client ID Metadata Documents replacing Dynamic Client Registration (DNS+HTTPS-anchored trust) [8][12]. - Tool-name guidance, SDK tiering, and input-validation → tool errors clarifications [8].
- Earlier (
2025-06-18): JSON-RPC batching removed — still gone [13].
Next (2026-07-28, release candidate, RC locked May 2026, final July 2026) — the largest revision since launch: a stateless protocol core that scales on ordinary HTTP infrastructure, an Extensions framework, promotion of Tasks, MCP Apps (server-rendered UI), and deeper OAuth/OIDC alignment [2][16]. For a session today, build against 2025-11-25 and treat the stateless core as the near-term direction of travel. The TS SDK [10] ⭐ 12.6k (Jun 2026) is Tier 1, so it tracks these within the spec’s validation window [2].
Minimum a server author must implement
- Pick a transport — stdio for local, Streamable HTTP for remote [3].
- Answer
initialize: echo a supportedprotocolVersion, declare your capabilities, sendserverInfo; wait fornotifications/initialized[4]. - Implement at least one primitive’s
*/list+ invoke pair, declaring its capability [5]. - Return tool failures as
isError: trueresults, not protocol errors [5]. - On Streamable HTTP: validate
Origin(DNS-rebinding), bind localhost when local, handleMcp-Session-IdandMCP-Protocol-Version[3][4].
The schema source of truth is schema.ts in the spec repo [11] ⭐ 8.3k — when an SDK’s types and the docs disagree, the schema wins [9].