lainlog

Chapter 7 of 9 · Model Context Protocol

Build a client, pick a transport

stdio for trust, HTTP for distance. and the smallest host that works.

You've built a server in chapter 6 — a small currency-server that exposes one tool and waits for someone to call it. The server sits there ready. Nothing happens until a consumer shows up. Most readers arrive thinking the consumer is the LLM. It isn't — the consumer is a client, and the client's first choice is also its hardest one: which transport does it speak.

The 2025-06-18 spec leaves you with two answers. There used to be three; one of them was deprecated this round, and the chapter exists partly to keep you from tripping over old tutorials that still reach for it. Before any of that, the opener — what does the spec say a remote MCP server should do when a request shows up without credentials?

What HTTP code should a remote MCP server return on an unauthenticated request?
What HTTP code should a remote MCP server return on an unauthenticated request?
Predict before you read on. Most readers reach for 403 — the spec reaches for 401, and the difference is load-bearing once OAuth enters the picture.

The client's job, in three sentences#

A client opens a sessionwith one server, drives the lifecycle, and exposes the discovered surface to the host. It speaks JSON-RPC over a transport you pick at construction time; every later request and notification rides that wire. One host, many clients — one per server, and that's the whole shape.

The smallest version that works fits on the back of an envelope. A name, a transport, a connect, a list, a call. Everything else is convenience.

minimal-client.ts (stdio)typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: "node",
  args: ["./currency-server.js"],
});

const client = new Client({ name: "minimal-client", version: "0.1.0" });
await client.connect(transport);

const { tools } = await client.listTools();
const result = await client.callTool({
  name: "convert",
  arguments: { amount: 100, from: "USD", to: "EUR" },
});

The interesting line is client.connect(transport). Under the hood it runs the three-message handshake from chapter 4 — same initialize request, same response, same notifications/initialized. Capability negotiation, version pinning, the gate before the session opens — all hidden behind one await. After it resolves, listTools and callTool are the two methods you reach for first.

The widget below shows what those five lines look like on the wire — the transport opens, the handshake runs, then tools/list pulls the menu. Six messages, then your client has something to call.

The init flow — connect through tools/list
The init flow — connect through tools/list0 of 6
message 1 of 6
Press Step (or Play) to send the first message. Six messages, then the tool catalogue is in hand.

The transport question#

The 2025-06-18 spec gives you two transports: stdio for same-machine, and Streamable HTTPfor everywhere else. They're not interchangeable; each picks a different set of trade-offs about trust, locality, and auth.

stdio

The client spawns the server as a child process and pipes JSON-RPC over stdin and stdout. Logs go to stderr. The session is bound to the process lifetime — when the process exits, you get an EOFon the read side and the session is over. No port to open, no TLS to terminate, no auth header to forge: same machine, same user, no boundary. It's the cheapest wire there is.

The trade-off lands on reach. stdio servers are single-instance, single-tenant, local-only. There's no way for a stdio server to be discovered, load-balanced, or accessed from a different machine. If your client and server share a process boundary, that's a feature; if they don't, it's a wall.

Streamable HTTP

The client sends JSON-RPC over a single HTTP endpoint — usually a POST — and the server replies with either a normal JSON response or a streamed SSE body when it has more than one message to deliver (think a long-running tool call that wants to push progress notifications mid-flight). Auth is the same auth your web infra already speaks: an Authorization header, OAuth scoping, an HTTP-layer session id.

The session identity moves out of the process and into a header. The server hands the client a session id on the response to initialize; every subsequent request carries it. That's how a load-balanced, multi-instance, multi-tenant deployment can route a tool call back to the same logical conversation — whichever instance happens to take the next request can hydrate state from the id.

minimal-client.ts (Streamable HTTP variant)typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
  StreamableHTTPClientTransport,
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://api.example.com/mcp"),
  { requestInit: { headers: { Authorization: `Bearer ${token}` } } },
);

const client = new Client({ name: "minimal-client", version: "0.1.0" });
await client.connect(transport);

Same client, same connect, same handshake under the hood. The only thing that changed is the transport constructor — a URL and a header in place of a command and args. That's by design: the SDK keeps the client surface identical, so your call sites don't care which wire you picked.

The HTTP+SSE deprecation, named#

Earlier MCP specs shipped a different remote transport — call it HTTP+SSE. Two endpoints: a POST endpoint the client wrote to, and a separate SSE endpoint the client kept open to receive server-initiated messages. It worked, but it asked every deployment to keep two paths in sync, with auth scoping at two boundaries instead of one. The 2025-06-18 spec deprecated the pairing in favour of Streamable HTTP — one endpoint, optional streaming on response. If a tutorial sends you toward /sse and /messages as separate paths, it's pointed at the deprecated shape.

Pick a transport for your scenario#

Trust, locality, and auth are the three axes the choice sits on. Same machine, same user, no auth boundary — stdio. Off-machine, multi-tenant, or auth lives at HTTP — Streamable HTTP. The widget below walks four real deployments. Pick the transport you'd ship; wrong picks reveal what would actually break.

Pick a transport — four real deployments
Pick a transport — four real deployments0 / 4
Pick the transport you'd ship for each deployment. Wrong picks reveal what would actually break — the lesson is in the failure mode, not the verdict.

Operational concerns, in two paragraphs#

Transports fail. With stdio the failure mode is loud and process-shaped: the child crashes, the read pipe gets an EOF, the client surfaces a transport error. There's nothing to retry — the session is gone with the process. With Streamable HTTP the failure mode is quieter: a connection drops, a load balancer reaps an idle session, a server instance restarts. The client typically re-issues the request; the spec's session header is what makes the retry idempotent across instances.

Session expiration is the second thing to budget for. A long-lived stdio session lives as long as the process; if you kill the parent host, the children die with it. A Streamable HTTP session lives as long as the server agrees — minutes, often — and a stale session id will get a fresh 401 or a 404 depending on the server's policy. Either way, the client has to be willing to re-handshake. It's the same shape as re-establishing any other web session.

Comprehension check#

Your remote MCP server is behind a load balancer. A client connects, sends initialize, and gets back an LB-assigned session id. Twenty minutes later it sends tools/call. What happens?

reveal answer

It depends on the load balancer's session affinity. If the LB pins the session id to the same instance, the request lands on the instance that holds the conversation state and succeeds. If it doesn't — round-robin, instance died, deploy rotated — the request lands on a fresh instance that has never seen this session id and either rejects with a 404 (or 401, depending on the server's policy) or hydrates state from a shared store. This is precisely why the spec carries a session header and why session resumption matters — Streamable HTTP makes the routing problem explicit so the deployment can solve it instead of pretending the session lives in process memory.

The session is open. Now what?#

You've got a server, a client, a transport, and the primitives the host can reach for. Most messages flow client-to-server: the host asks for the catalogue, calls a tool, reads a resource. But sometimes the server has to ask back — when it needs the LLM to complete a prompt, when it needs the user to answer a question, when it needs the host to widen its filesystem scope. The client's capability declarations are the licence for those reverse calls. That's chapter 8.