Where Does the MCP Client Actually Live?
Part 1 of 6: mapping MCP's host, client, and server before the legacy-to-modern transition
This is part 1 of a six-part series called “Building an MCP Client in React.” By the end of the series we’ll have built a small browser app together, but this first part is about drawing the map before writing any code. If you’ve never heard of MCP (Model Context Protocol), or you only have a vague sense that “it’s some way of connecting AI to tools,” this part is for you: we’ll sort out who the players are, and what transition the technology is in the middle of right now.
The App: One That Just Returns a Table
Let’s start with the example we’ll use throughout the series. It does one simple thing. A user types a natural-language question into a text box — something like “show me the top 10 products by sales last month” — and the app turns that into SQL, queries a relational database, and renders the result as a table. It’s a minimal browser app with only as much backend as it needs.
At a glance this looks like a typical “let AI write your SQL” app, but break it apart and three distinct roles fall out.
- The app itself, which takes the user’s question and draws the table
- The LLM API, which turns natural language into SQL
- The database gateway, which runs the SQL and hands the table structure and results back to the app
MCP is an attempt to standardize that third piece: how you build the database gateway. And here’s the part that trips people up: the term “MCP client” doesn’t refer to the whole app — it refers to one small piece that talks to that gateway. Where does the host end and the client begin, and where does the server start? Get this wrong and every later post about headers and sessions will feel like it’s talking about something else entirely. So we fix it here, first.
What MCP Actually Is
MCP (Model Context Protocol) is a standardized protocol for connecting LLM applications to external data and tools. The official spec describes its purpose in three parts: sharing contextual information with a model, exposing tools and capabilities to an AI system, and building composable integrations and workflows.
It’s built on top of JSON-RPC 2.0. In short, JSON-RPC is a lightweight convention for remote calls: you send a request with a method name and parameters, and you get back either a result or an error (the exact wire format isn’t the point of this article, so see the primary sources at the end if you want the details). MCP layers its own meaning on top of that JSON-RPC envelope — which methods exist, and who’s allowed to call them.
The official spec points to a precedent for this kind of design: the Language Server Protocol (LSP). Just as LSP standardized how editors add support for programming languages, MCP standardizes how AI applications add context and tools.
Three Parties: Host, Client, Server
The spec splits the parties in a conversation into three roles.
| Role | What the spec says | Where it sits in our running example |
|---|---|---|
| Host | The LLM application that initiates connections | The app that receives the user’s question |
| Client | A connector living inside the host, responsible for one connection to a server | The piece that talks to the database gateway |
| Server | A service that provides context or capabilities | The service that returns table definitions and query results |
“Client” doesn’t name the whole app — it names a small connector inside the host that owns one connection to one server. Read literally, the series title “Building an MCP Client” really means “embedding, inside the host, a client that talks to a server.”
The spec organizes what a server is allowed to offer into three kinds of features.
- Resources: context and data for the user or the model to use
- Prompts: templated messages and workflows for the user
- Tools: functions the model can execute
In the other direction, the spec lists Elicitation — a server asking the user for more information — as something a client can offer to a server.
Mapped onto our running example, it’s natural for the database server to expose the table definitions as a Resource and the “run this SQL” capability as a Tool. The host fetches the schema via that Resource, hands it to the LLM API with a prompt like “write SQL against this schema,” gets SQL back, calls the Tool with it, and turns the result into a table. What matters here is that calling the LLM API sits outside MCP. What MCP standardizes is how the host gets context and capabilities from the server — not how the host talks to the model. The concrete design of a server’s Resources and Tools (things like inputSchema and outputSchema) is part 3’s topic; the host-side implementation is part 4’s.
There’s one more constraint worth calling out from the spec’s “message patterns” page. “Servers MUST NOT initiate JSON-RPC requests.” When a server needs something from the client mid-request — a model completion (sampling), a confirmation from the user (elicitation), or a filesystem root — it can’t simply fire off a new request. Instead it answers the in-flight request with an InputRequiredResult, and the client resends the same request with inputResponses attached. The spec calls this pattern Multi Round-Trip Requests (MRTR). Elicitation, mentioned above, is implemented through exactly this MRTR mechanism. The mechanics of an actual MRTR round trip are outside what this series measures (this paragraph is a description of the spec’s text; we haven’t exercised it against a real implementation).
This figure illustrates a pattern the spec defines; we haven’t measured it against a running implementation.
Two Eras: One Handshake, or a Version on Every Request
The MCP spec went through a real fault line at the 2026-07-28 revision. The spec’s “Versioning and Compatibility” page names the two sides of that fault line legacy (2025-11-25 and earlier) and modern (2026-07-28 and later).
Legacy: One Handshake, Then You’re In
In versions up to 2025-11-25, a client’s first move is to call a method named initialize, negotiating supported versions and capabilities with the server. Once that succeeds, every following request is treated as part of that session. Do the handshake once, and everything after it is just a continuation of that same session.
Modern: State Your Version, Every Time
From 2026-07-28 on, there’s no such thing as a handshake up front. The spec puts it directly:
There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently.
In practice, each request carries its protocol version in the _meta field of the body. If the server doesn’t support the requested version, it’s supposed to reply with an UnsupportedProtocolVersionError (error code -32022), listing the versions it does support.
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2026-07-28", "2025-11-25"],
"requested": "1900-01-01"
}
}
}
The spec also requires every server to implement server/discover. A client may call it up front to learn what a server supports, but it doesn’t have to — it’s equally valid to just call the method it actually wants and handle an UnsupportedProtocolVersionError if it comes back.
Dropping the session as a unit also reshaped the transport layer. The two standard transports are stdio (newline-delimited messages over the standard streams of a client-launched subprocess) and Streamable HTTP (a single endpoint that takes HTTP POSTs). The Streamable HTTP spec states it plainly:
Removal of the GET stream endpoint. / Removal of protocol-level sessions.
In other words, both the always-open GET stream and the very concept of a protocol-level session were removed from the spec at 2026-07-28. We’ll cover Streamable HTTP’s actual shape — required headers, the _meta mirroring, the HeaderMismatch error — in part 2.
What Happens During the Transition
While legacy and modern coexist, the outcome depends on which combination of client and server you’re running. The spec includes a full compatibility matrix; here’s the part that matters for this transition period.
| Client | Server | Outcome |
|---|---|---|
| modern | modern | Works. A version mismatch surfaces as UnsupportedProtocolVersionError, and the client retries with a supported version |
| modern | legacy | Fails. A legacy server doesn’t understand the modern per-request declaration format |
| legacy | modern | Fails. A modern server doesn’t treat initialize as a required entry point |
| dual-era | either | Works. The client detects the other side’s era and adapts |
What this table tells you is that “implement it the way the newest spec reads” doesn’t guarantee it’ll work. The outcome depends on which era the thing you’re actually talking to — in our case, a server library — has caught up to, regardless of how your client code is written. That’s the theoretical picture; the next section is what actually happened when we tried it.
What Actually Happens When You Build This Today
Everything from here on is something we ran, not just something we read. The environment was Node.js v22.12.0, the official TypeScript SDK (@modelcontextprotocol/sdk) at version 1.30.0, run on 2026-08-06. The server was a minimal MCP server with a single echo tool that we built ourselves — it doesn’t depend on any particular product or service.
The Spec Is Ahead of the SDK
First, we checked which protocol version the SDK actually implements.
LATEST_PROTOCOL_VERSION = 2025-11-25
DEFAULT_NEGOTIATED_PROTOCOL_VERSION = 2025-03-26
SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25","2025-06-18","2025-03-26","2024-11-05","2024-10-07"]
The spec’s current revision is 2026-07-28, but the latest official TypeScript SDK (1.30.0) only implements up to 2025-11-25. That means the SDK is still working entirely within the legacy era. Searching the package contents turned up neither the string 2026-07-28 nor a method named server/discover.
Sure enough, when we announced 2026-07-28, the request was rejected:
--- response status ---
400 Bad Request
--- response body ---
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"},"id":null}
One gap here is worth flagging directly. The spec asks for UnsupportedProtocolVersionError (code -32022) with a data.supported list on an unsupported-version response, but what we actually got was a different code, -32000, and a prose message with the supported versions embedded in the text. Read this with the expectation that the implementation may not have caught up to the spec’s newer error vocabulary yet.
What this means is that “the current state of the spec” and “what you can actually write against the SDK today” have to be treated as two separate things. The hands-on parts of this series (part 2 onward) will prioritize code that actually runs, which means building on the legacy-era approach the SDK supports today, and calling out what changes under the modern era as we go.
A Plain POST Fails Because of Accept, Not the Handshake
The Streamable HTTP spec requires the client to list both application/json and text/event-stream in its Accept header. Send a plain POST with only Content-Type set, skipping that, and you get a 406 before anything else happens:
--- response status ---
406 Not Acceptable
--- response body ---
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}
This is worth underlining: the real reason a plain POST doesn’t work against an MCP server is the Accept header, not the presence or absence of a handshake. This check happens before the body is even read, so a perfectly well-formed JSON-RPC request still gets the same 406 if Accept is missing. We’ll go through Streamable HTTP’s required headers and how to read its responses in part 2.
Whether You Need initialize Depends on the Server’s Session Mode
There’s one more thing we got wrong ourselves before actually testing it. It is not true that calling a method without going through initialize always fails. With the server built stateless (no session ID issued), tools/list and tools/call both came back 200 with no handshake at all, as long as Accept was satisfied.
--- response status ---
200 OK
--- response body ---
event: message
data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":6}
Rebuild that same SDK’s server as stateful (issuing a session ID), though, and a request that skips initialize gets rejected:
--- response status ---
400 Bad Request
--- response body ---
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Server not initialized"},"id":null}
Every subsequent request also needs the Mcp-Session-Id issued during that handshake attached. So whether you need the handshake is less a rule of the protocol than a property of how the server’s session mode is implemented. The fact that stateless implementations already exist in the wild is a small hint of the direction the protocol eventually took: dropping protocol-level sessions entirely at 2026-07-28.
Mapping the Running Example Back
Let’s put all of this back onto the example we opened with.
The host — the app itself — holds an MCP client connector internally, and uses it to talk to the database’s MCP server. The server exposes a Resource that returns table definitions and a Tool that runs SQL. Calling the LLM API to draft the SQL happens outside MCP, directly in the host. And whether that conversation runs on a legacy handshake or a modern per-request declaration depends entirely on which era the server implementation you’re connecting to has landed on. Build against today’s official SDK, and in practice you’re following legacy-era rules: whether you need initialize depends on the server’s session mode, and Accept must list both content types either way.
What’s Coming in This Series
Part 1 stops at the map. The specifics get their own parts.
| Part | What it covers |
|---|---|
| 2 | Streamable HTTP in detail — required headers, _meta mirroring, HeaderMismatch, SSE, Origin checks |
| 3 | Server-side design — tools and resources, inputSchema / outputSchema, x-mcp-header |
| 4 | The React side — can you call it straight from the browser, do you need a BFF, reading SSE, cancellation |
| 5 | Work that can’t just wait — notifications/progress, subscriptions/listen, the Tasks extension, and cancellation being cooperative |
| 6 | Finishing and looking back — authorization, dual-era compatibility, whether it’s safe to run generated SQL as-is |
The Tasks extension covered in part 5 is one of the opt-in extensions the official spec lists: it uses polling, mid-flight input, and durable handles to run long operations asynchronously (this paragraph is describing what the spec says; we haven’t exercised it).
Wrapping Up
MCP connects a host, a client, and a server over JSON-RPC 2.0. “Client” doesn’t name the whole app — it names the small connector inside the host that owns one connection to one server. The 2026-07-28 revision made explicit a transition from a legacy era that establishes a session through a handshake to a modern era that states its protocol version on every request via _meta, but the latest official TypeScript SDK still only works within the legacy era. Testing it directly, the real reason a plain POST fails is a missing Accept header, and whether you need initialize depends on the server’s session mode. Keeping the spec’s language separate from the language you can actually write today turns out to be the first real step toward getting this running. Next time, we’ll dig into Streamable HTTP’s headers and the shape of its SSE responses, working from actual traffic.
Articles in This Series
Read from Part 1 and the series runs as one line, from mapping the spec to code that runs in a browser. Each part also stands on its own, so jump around from here whenever you want to.
- Part 1: Where Does the MCP Client Actually Live? (this article) — host, client, and server, and the legacy-to-modern fault line
- Part 2: What Does an MCP Server Actually Listen For? — required Streamable HTTP headers, SSE, and Origin validation
- Part 3: What Does an MCP Server’s Counter Actually Hand You? — schemas, annotations, and the two ways a call fails
- Part 4: Can React Talk to an MCP Server Directly? — CORS, a BFF, reading SSE, and cooperative cancellation
- Part 5: How Does MCP Handle Work You Can’t Wait For? — progress notifications, subscriptions, and the Tasks extension
- Part 6: Whose Job Is It to Open That Door? — authorization, legacy/modern detection, and running generated SQL
Primary Sources
- MCP Specification (latest) — the three-party structure, JSON-RPC 2.0, Resources/Prompts/Tools, Elicitation, and an overview of the Tasks extension
- Versioning and Compatibility (2026-07-28) — the legacy/modern definitions, handshake removal,
UnsupportedProtocolVersionError,server/discover, the compatibility matrix - Message Patterns (2026-07-28) — the rule that servers must not initiate JSON-RPC requests, and the description of Multi Round-Trip Requests
- Transports Overview (2026-07-28) — the two standard transports, stdio and Streamable HTTP
- Streamable HTTP (2026-07-28) — removal of the GET stream and protocol-level sessions, the
Acceptheader requirement - Tasks Extension Overview — the extension covered in part 5
- JSON-RPC 2.0 Specification — the message format MCP is built on








