What Is the Model Context Protocol? Hosts, Clients, Servers and the 2026-07-28 Spec
MCP is a JSON-RPC based protocol that lets you write tools and data sources once as a server and connect them to any AI application through a standard client. We watch the wire with hand-written JSON messages to see the host, client and server roles, the tools/resources/prompts primitives, the stdio and Streamable HTTP transports, and the 2026-07-28 revision that removed the handshake.
Version note: The code in this post was built and run with .NET 10, the MCP (Model Context Protocol) spec 2026-07-28 and ModelContextProtocol 2.4.0.
Guess First
You want to give the Bookstore assistant a tool that answers order status questions. In this post we will write a C# program of roughly thirty lines; it will be an MCP server with a single tool, get_order_status. Then we will send it three lines of hand-written JSON (JavaScript Object Notation) from the terminal. The question: if your first message is not a "hello, I speak this version" handshake, does the server hand over its tool list, or does it insist on initialize first?
A year ago the answer would have been "initialize first". The 2026-07-28 revision removed the handshake entirely: every request carries its own version and capabilities, and the server judges each request on its own. In this post we look at the protocol's roles, its messages and what this revision changed, by watching the wire.
The N×M Problem and MCP's Answer
In posts 11 and 12 of the LLM (Large Language Model) Fundamentals series we saw the mechanics of tool calling: the model does not call functions, it asks for a call, and your code makes it. In the first seven posts of this series we built the agent loop, tool design and the approval flow on top of that. In all of them the tools lived inside the application: we turned a C# method into a tool with AIFunctionFactory.Create and ran it in the same process.
Now widen the picture. The company has three AI (Artificial Intelligence) applications: the customer assistant, the coding assistant inside the IDE (Integrated Development Environment) and an internal operations bot. There are five systems to connect to: the order database, the carrier's API (Application Programming Interface), the stock service, the document store and the ticket system. If every application writes its own integration to every system, that is 3×5 = 15 separate integrations, and every new application means five more. MCP exists to turn this N×M problem into N+M: each system is written once as an MCP server, each application learns once to speak as an MCP client.
In post 20 of LLM Fundamentals we saw that Anthropic proposed MCP as an open standard in November 2024. The spec openly says it takes inspiration from LSP (Language Server Protocol): just as LSP solved the problem of every editor writing a separate plugin for every language, MCP standardizes how AI applications integrate tools and data.

The spec defines three roles, and mixing them up turns into design mistakes later:
- Host: the LLM application the user interacts with. It calls the model, asks the user for consent and decides which tools the model gets to see.
- Client: a connection object inside the host, bound to a single server. In the C# SDK (Software Development Kit) this is
McpClient; a host connected to three servers holds three clients. - Server: the program that offers tools, resources and prompts. It does not see the model or the other servers; it only sees the requests that reach it.
The practical consequence: security and consent decisions are made in the host. A server can send a hint saying "this tool is harmless", but it is still the host that has to verify it. We will come back to this in post 13.
Messages on the Wire: Hand-Written JSON-RPC
Let's leave theory and write the smallest server. Post 9 covers the SDK in detail; for now the goal is to see the wire:
using System.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services
.AddMcpServer(o => o.ServerInfo = new() { Name = "bookstore-hello", Version = "0.1.0" })
.WithStdioServerTransport()
.WithTools<OrderTools>();
await builder.Build().RunAsync();
[McpServerToolType]
public sealed class OrderTools
{
private static readonly Dictionary<int, string> Orders = new()
{
[1043] = "Shipped, tracking TRK-88213",
[1044] = "Processing",
};
[McpServerTool(Name = "get_order_status", ReadOnly = true)]
[Description("Returns the status of a Bookstore order.")]
public static string GetOrderStatus([Description("Order number, e.g. 1043")] int orderId) =>
Orders.TryGetValue(orderId, out string? status)
? $"Order {orderId}: {status}"
: $"Order {orderId} was not found.";
}
This server uses the stdio transport: the client starts the server as a child process, writes messages to its stdin and reads responses from its stdout. Each message is a single-line JSON-RPC (JSON Remote Procedure Call) 2.0 message. So we can play the client by hand:
#!/bin/zsh
# The same _meta on every request: version, client identity, client capabilities
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"probe","version":"0.1"},"io.modelcontextprotocol/clientCapabilities":{}}'
{
echo '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{'$META'}}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{'$META'}}'
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_order_status","arguments":{"orderId":1043},'$META'}}'
sleep 2
} | dotnet Hello/bin/Debug/net10.0/Bookstore.Mcp.Hello.dll 2>server.log
There is no initialize on the first line. The first and third of the three lines the server writes to stdout:
{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true}},"ttlMs":0,"cacheScope":"private","resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-hello","version":"0.1.0"}}},"id":1,"jsonrpc":"2.0"}
{"result":{"content":[{"type":"text","text":"Order 1043: Shipped, tracking TRK-88213"}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-hello","version":"0.1.0"}}},"id":3,"jsonrpc":"2.0"}
And the single tool in the tools/list response in between:
{"name":"get_order_status","description":"Returns the status of a Bookstore order.","inputSchema":{"type":"object","properties":{"orderId":{"description":"Order number, e.g. 1043","type":"integer"}},"required":["orderId"]},"annotations":{"readOnlyHint":true}}
These three lines hold the skeleton of the protocol. The server/discover response tells you in one go which versions the server speaks, which capabilities it offers and who it is. tools/list returns a JSON Schema generated from the C# method signature; this schema and the description are exactly what the model will see. tools/call returns the result as content blocks. Every response carries resultType: "complete", and the server's identity now comes back inside _meta.
The server.log shows a small but instructive detail:
Server (bookstore-hello 0.1.0), Client (probe 0.1) method 'tools/list' request handler called.
Server (bookstore-hello 0.1.0), Client (probe 0.1) method 'tools/call' request handler called.
Server (bookstore-hello 0.1.0), Client (probe 0.1) method 'server/discover' request handler called.
We sent the requests in order 1, 2, 3, but the server processed them in parallel. What ties responses to requests is not order but the JSON-RPC id. If you write your own client, the assumption "responses come back in the order I sent them" breaks under the first real load.
Primitives: Tools, Resources, Prompts
The spec calls the three things a server offers "primitives", and each one is controlled by a different party:
| Primitive | Who uses it | Bookstore example | Methods |
|---|---|---|---|
| Tools | The model asks for a call, the host runs it | get_order_status, search_books | tools/list, tools/call |
| Resources | The application or user picks them and puts them in context | bookstore://books/3 | resources/list, resources/read |
| Prompts | The user picks them (e.g. a slash command) | recommend_books | prompts/list, prompts/get |
The distinction matters because the trust model differs. A tool is an action; searching for a book is harmless, cancelling an order is not. A resource is data; it enters the model's context but does nothing by itself. A prompt is a ready-made message template; the user picks a "recommend me software books" command and the server produces the message.
In the opposite direction, the only active feature a client offers to a server is elicitation: the server asks the user for information or confirmation in the middle of an operation. Roots, sampling and logging became deprecated in 2026-07-28; they keep working, but new code is asked not to adopt them. The spec suggests replacements: pass directories through tool parameters or configuration, call the LLM directly from the server, and write logs to stderr or OpenTelemetry.
Transports: stdio and Streamable HTTP
The message format stays the same; the channel carrying it changes. There are two standard transports.
With stdio, the client starts the server as a child process. Messages travel line by line and cannot contain embedded newlines. The server must not write anything to stdout except MCP messages; stderr is free for logs. Shutdown is simple too: the client closes stdin and the server exits. The last lines of our log show exactly that: transport completed reading messages followed by shutting down. This is the natural choice for local, single-user tools; the authorization spec also says stdio servers should take credentials from the environment instead of running an OAuth flow.
With Streamable HTTP (HyperText Transfer Protocol), the server is an independent service. There is a single endpoint (usually /mcp) and every JSON-RPC request is a separate POST. The response is either plain application/json or an SSE (Server-Sent Events) stream scoped to that one request, carrying progress notifications first and the response last. 2026-07-28 removed the GET stream and the session header, and in exchange made the MCP-Protocol-Version, Mcp-Method and, where applicable, Mcp-Name headers mandatory on every POST. A load balancer or gateway can now say "this is a tools/call for add_to_cart" without opening the body. In post 10 we try them one by one with curl.
What Did the 2026-07-28 Revision Change?
The C# SDK's 2.0 announcement describes this revision as the largest protocol revision so far. The important items in the changelog:
- Protocol-level sessions and the
Mcp-Session-Idheader are gone (SEP-2567; SEP: Specification Enhancement Proposal). Servers that need state across calls pass their own server-minted handle (e.g.basketId) as an ordinary tool argument. - The
initialize/notifications/initializedhandshake is gone (SEP-2575). Every request carriesio.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilitiesin_meta, and the server reports its identity in the result's_meta. server/discoverwas added; servers must implement it, clients may call it.- The HTTP GET stream and
resources/subscribewere replaced by a singlesubscriptions/listen: the client says which notifications it wants and the server sends them on a long-lived stream. - Instead of the server sending its own requests to the client, there is MRTR (Multi Round-Trip Requests) (SEP-2322): the server returns what it needs with
resultType: "input_required", and the client retries the same request with the answers attached. - Every result has a mandatory
resultTypefield; a result from an older server that lacks it counts as"complete". - List responses such as
tools/listgainedttlMsandcacheScope; the client learns from them how long it may cache the list. Our server returnedttlMs: 0and"private", meaning "don't cache". - Tasks moved out of the core into an optional extension; roots, sampling and logging are deprecated, and so are the HTTP+SSE transport and Dynamic Client Registration.
All of these changes point the same way: every request should make sense on its own. Without sessions, two requests can land on different machines, sticky sessions are unnecessary, and a server restart cannot break a half-finished handshake. There is a price: state you used to keep in the session now has to be carried explicitly.
Dated Versions and Living with Older Servers
MCP versions are dates, not semver: 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 and 2026-07-28. The date marks the day the revision was published, and backward-incompatible changes only arrive with a new date. The spec also adopted a deprecation policy: a deprecated feature stays in the spec for at least twelve months.
So what happens when an old client speaking 2025-11-25 connects to our server? Let's go to the same server with the old-style handshake:
{"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"tools":{"listChanged":true}},"serverInfo":{"name":"bookstore-hello","version":"0.1.0"}},"id":1,"jsonrpc":"2.0"}
{"result":{"content":[{"type":"text","text":"Order 1044: Processing"}]},"id":2,"jsonrpc":"2.0"}
SDK 2.x is a "dual-era" server that speaks both eras: when initialize arrives it continues under the old rules, the responses have no resultType, and the deprecated logging capability comes back. A client asking for a version the server does not know gets the standard error:
{"error":{"code":-32022,"message":"Unsupported protocol version '2027-01-01'.","data":{"supported":["2026-07-28"],"requested":"2027-01-01"}},"id":1,"jsonrpc":"2.0"}
Notice that the supported list only contains 2026-07-28, even though the server also accepts the old handshake. That is why the spec's compatibility rule is not "fall back based on the error code": on stdio the client sends server/discover first; if it gets a DiscoverResult or a recognized modern error, the server is modern; any other error or a timeout means falling back to initialize. On HTTP the same decision is made by looking at the body of the 400 response. The C# client makes this decision itself; in post 12 we will print which version the client settled on.
In Production
First, write down and test which spec revision you target. Hosts will not all move to 2026-07-28 at once; even the MCP Inspector 2.14.0 CLI (Command-Line Interface) I used while writing this post connected to our server with initialize. One integration test per era, proving your server works in both, answers the future question "why doesn't it show up in that IDE" up front.
Second, cacheable list responses are a contract. If you return a ttlMs, the client may not ask for the tool list again for that long; after a deploy that adds a tool, the old list stays visible for a while. The spec also asks for tools/list to come back in a deterministic order; if the order changes every time, both the client cache and the LLM providers' prompt cache are wasted.
Third, serverInfo is the server's own claim. The spec explicitly says it is for display, logging and debugging only and must not be used for security decisions. If you write an allowlist like "if its name is bookstore it is trusted", any server can use that name.
Fourth, observability. In 2026-07-28 the spec defined how OpenTelemetry trace context travels in _meta under the traceparent, tracestate and baggage keys. A single trace reaching from the host to the server and from the server to the API behind it will be useful in post 14 when we inspect agent trajectories.
Common Pitfalls
First, thinking MCP replaces tool calling. The model still asks for calls through the same tool calling mechanism; MCP only standardizes where the tool lives and how it is discovered. Writing an MCP server for three tools that live in one application and one process is often an unnecessary layer.
Second, copying examples from older blog posts without checking their date. An example written in 2025 expects Mcp-Session-Id, sends elicitation/create requests from server to client and opens a GET stream. None of these exist in 2026-07-28.
Third, polluting stdout on stdio. If the server writes a single character other than an MCP message to stdout, the line the client reads is corrupted. In post 9 we will see, with real output, how this turns into a one-minute timeout.
Fourth, confusing host, client and server. When you say "my MCP server should ask the user for consent" you are really asking something of the host; the server can only say "I need confirmation" through MRTR, and it is the host that shows it to the user.
A Small Experiment
★☆☆ Add a fourth line to the probe script and send tools/call with "abc" as the orderId. Is the response a JSON-RPC error or a tool result with isError: true? Think about what the difference tells the model.
★★☆ Remove io.modelcontextprotocol/protocolVersion from _meta and send the same tools/list request, then set it to "2025-11-25". Do resultType, ttlMs and _meta appear in both responses? Which era does the server assign the request to?
★★★ Write a small probe that implements the spec's stdio compatibility rule: send server/discover first and fall back to initialize if neither a result nor -32022 arrives within two seconds. Run it against the server in this post and against an older server built with a 2025-era SDK.
You now know what flows on the wire. In the next post we turn this small program into a real Bookstore server: tools that take services from dependency injection with the ModelContextProtocol SDK 2.x, structured output, resource and prompt definitions, and testing with MCP Inspector.