How MCP Clients and Servers Work Together
How MCP clients and servers work together: the host, client and server split, the request lifecycle, and what the stateless 2026-07-28 spec changed.
An MCP client and an MCP server split one job. The server owns the tools and the data. The client owns the connection and the model's turn. Between them runs JSON-RPC 2.0, and since the 2026-07-28 spec revision, every request stands on its own with no session behind it. That last part breaks most of what's written about how MCP clients and servers work together, including a lot of it published this year.
If you've read that a client and server "shake hands and open a session," that's the old model. Here's the current one, and where the two-way contract actually bites.
What is an MCP client, and what is an MCP server?
An MCP server is a program that exposes tools, resources, and prompts. An MCP client is the piece inside an AI app that talks to exactly one server. The architecture overview is blunt about the ratio: one client per server, always.
Host, client, server: three roles, not two
The host is the app. Claude Code, VS Code, a chat product you built. It runs the model, holds the conversation, and decides what happens.
The client is a connection object the host creates. Connect to four servers and the host spins up four clients. Each one keeps its own connection and knows nothing about the others.
The server is the thing you write. It wraps a database, an API, a filesystem, a CMS. It answers requests and has no idea which model is on the other end.
That separation is why an MCP server you build for Claude Code also works in VS Code. The server never talks to a model.
Local servers and remote servers
Stdio transport means the host launches your server as a child process on the same machine. One client, one process, no network. Streamable HTTP means your server runs somewhere else and serves many clients over POST, with bearer tokens or OAuth on the front.
Same protocol either way. The transport layer sits outside the data layer, so the JSON-RPC messages don't change.
How a request travels from the model to the server
Four steps, and only two of them involve the model.
The host sends server/discover to learn what the server supports. Then tools/list to read the tool names and their JSON Schemas. Those schemas go into the model's context as callable functions. When the model picks one, the host sends tools/call and feeds the result back into the conversation.
What server/discover returns
Every server must implement it. The response carries supported protocol versions, the capability object, server identity, and two caching fields.
{
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": { "listChanged": true } },
"ttlMs": 3600000,
"cacheScope": "public"
}
Calling it is optional. A client can fire tools/list cold and handle an UnsupportedProtocolVersionError if the versions don't line up.
Why the tool description is the real interface
tools/call matches on name, exactly. But the model never sees your code. It sees name, title, description, and inputSchema. That text is your API surface for a reader that can't read the source.
Name tools like weather_current, not get. The official example uses calculator_arithmetic for the same reason.
What the 2026-07-28 spec changed
This is where older explainers go stale, and it's worth being specific about which parts.
The handshake and the session are gone
The initialize and initialized exchange is retired. So is the Mcp-Session-Id header. Requests now carry the protocol version, client identity, and client capabilities in a _meta field, on every call.
Three keys do that work: io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo, and io.modelcontextprotocol/clientCapabilities.
For a server author, this is good news. No session store. No reconnect logic. A request either has what it needs or it doesn't.
Servers can't call the client mid-stream anymore
They used to hold a stream open and push a request back. Now a server that needs user input returns resultType: "input_required" with the requests attached. The client collects answers and retries the original call with inputResponses. The spec calls this pattern Multi Round-Trip Requests.
Elicitation runs on it. So does sampling, which is deprecated as of 2026-07-28, along with logging and roots. There's a twelve-month minimum window before removal, so existing code keeps working. New servers should skip them.
Notifications are opt-in now
A client that wants change events opens a long-lived stream with subscriptions/listen and names the types it wants. The server acknowledges with the subset it agreed to honor. Then it sends things like notifications/tools/list_changed on that stream.
Delivery is best effort. The spec says so plainly, so clients should still poll.
Who does what?
| Job | Server | Client |
|---|---|---|
| Expose tools, resources, prompts | Yes | No |
| Validate arguments against the schema | Yes | Should |
| Hold the conversation and the model | No | Host does |
| Ask the user for confirmation | Requests it | Renders it |
| Cache list results | Sets ttlMs | Honors it |
| Enforce who may write | Yes | No |
That last row gets skipped a lot. A client won't stop a destructive call for you. If your server exposes a delete tool, the server checks the token's scope.
The underused angle: caching moved into the protocol
Most write-ups still frame MCP as a chat between two processes. As of 2026-07-28 it's closer to a cached HTTP API, and that changes how you build a server.
Three things point the same direction. List results carry ttlMs and cacheScope, so a client can hold your tool list for the five minutes you allow. HTTP requests carry Mcp-Method and Mcp-Name headers, so a gateway can route and meter without parsing the JSON body. And with sessions gone, any request can land on any instance behind a load balancer.
So build the server like a web service. Stateless handlers. Cache headers you mean. Per-request auth. The chat framing was never doing you any favors.
Is an MCP server just a wrapper around an API?
Mostly, yes. And that's fine.
Under the tools you'll find the same REST calls you already have. What MCP adds is a shape the model can read: a name, a title, a plain-text description, and a JSON Schema for the inputs. That's the whole trick.
But a thin wrapper is a bad server. Your REST API has 60 endpoints. Ship 60 tools and the model picks the wrong one, because the tool list is context and context is a budget. Pick the dozen a model would actually reach for. Name them for the task, not the route.
The second gap is failure text. An HTTP 422 with a field path is fine for a developer. A model needs the sentence: which field, what it wanted, what to send instead. Write errors as instructions.
What breaks when the client and server disagree
Version mismatch is the loud one. The client sends a version the server doesn't accept, gets UnsupportedProtocolVersionError back with the supported list, and retries. Loud is fine. It's recoverable.
Capability mismatch is the quiet one. Your server needs elicitation to confirm a destructive action. The client never declared it. Now the confirmation never renders, and what happens next depends entirely on how you wrote the fallback. Check clientCapabilities on the request before you depend on a client feature.
Stale tool lists are the third. A client cached your list for an hour, you shipped a new tool, and the model can't see it. Set ttlMs to what you can live with, not to the biggest number that fits.
Do you need to write a client?
Usually not. If you want an agent to use your product, write a server. Claude Code, Claude Desktop, VS Code and Cursor already ship clients, and they'll pick your server up from a config file.
Write a client when you're building the host: your own agent, your own chat product, your own IDE. That's a different project. It means managing one connection per server, merging tool lists across them, and deciding what the model is allowed to call. Our guide to MCP servers covers the server side, and the build walkthrough has working code.
Where this lands for a CMS
Draftbase ships an MCP server with 26 tools, so an agent can create templates, write entries, upload media, and roll back a revision. Everything the dashboard does, over the same protocol.
The interesting part isn't the tool call. It's the boundary. An agent with write access to your content needs scoped keys and an undo path, which is why every entry keeps a full revision history and every change can be rolled back. Draft status is the other half: an agent writes a draft, a human publishes it.
Start on Hobby, point your agent at the server, and let it write one entry. Watch what it does to the schema before you give it more. The MCP integration page has the setup, and pricing starts at $49/mo for the Startup plan when you outgrow free.
Ship content that's built to be found
Draftbase generates schema, structured data, and a fast MDX editor for every post.
Frequently asked questions
What is the difference between an MCP client and an MCP server?
The server exposes tools, resources and prompts. The client opens one connection to one server and hands results to the host app. A host that connects to four servers runs four clients.
Does MCP still use the initialize handshake?
No. The 2026-07-28 spec dropped initialize, initialized and the Mcp-Session-Id header. Each request now carries its own protocol version, client info and client capabilities, all in the _meta field.
How does an MCP client find out what a server can do?
It sends server/discover. Every server must answer it. The reply lists supported versions, capabilities, server identity, and two cache hints: ttlMs and cacheScope.
Can an MCP server ask the user a question mid-call?
Yes, through Multi Round-Trip Requests. The server returns resultType input_required, the client gathers the answers, then retries the same call with inputResponses attached.
Do I need to build an MCP client for my product?
No, in most cases. Claude Code, Claude Desktop, VS Code and Cursor already ship clients. Build a server instead, and they will pick it up from a config file.