# Transports

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Protocol Architecture
Source: https://www.devtutorials.ai/learn/mcp/architecture/transports

> How MCP messages travel: stdio with process lifecycle, line framing and sandboxing; Streamable HTTP with one POST per request, SSE streams and MCP headers; legacy HTTP+SSE; and local versus remote trade-offs.

Think about two ways to talk to a coworker. If they sit next to you, you can just turn and speak. No phone, no address, and no password. If they work in another city, you call them. That call needs a number, a secure line, and it takes a little longer than talking face to face.

MCP works the same way. A server can run right next to the host on the same computer, or it can live far away as a web service. Each setup uses a different transport, but the messages still follow the same MCP rules.

**The main idea:** **stdio** connects a host to a server process on the same machine. **Streamable HTTP** connects a host to a server over the network. The JSON-RPC messages stay the same; only the delivery changes.

## A Real-World Example

Brightgrid uses both transports.

- Ravi, a developer, runs a small **files** server on his laptop. His code editor starts it as a program and talks to it over **stdio**. The server can read his project folder, and nothing leaves his machine.
- The **help-desk** server runs in Brightgrid's cloud. The support app, the phone app, and the email bot all reach it over **Streamable HTTP**. One server serves everyone.

**In simple words:** stdio is a private line inside one computer. Streamable HTTP is a phone line across the internet.

## What You Will Learn

In this lesson, you will learn:

1. How stdio works: process lifecycle, message framing, logging, and sandboxing.
2. How Streamable HTTP works: POST requests, streamed replies, and MCP headers.
3. What the older HTTP+SSE transport was and why it is deprecated.
4. How to choose between local and remote servers.

The **MCP Lab** uses Streamable HTTP. The client and server run inside your browser tab, and the HTTP requests travel directly between them without leaving the browser. Every header and body you see in the Inspector is real.

## stdio

### Inter-Process Communication

**stdio** stands for **standard input and output**. Every program on your computer has:

- **stdin**, where it reads input.
- **stdout**, where it writes output.
- **stderr**, where it writes errors and logs.

With the stdio transport, the host starts the MCP server as a **child process**. That means the host launches a program and keeps control of it. The client writes requests to the server's **stdin**. The server writes replies to its **stdout**. This is called **inter-process communication**: two programs on the same computer talking to each other.

This setup is useful when the server is meant to work only for one user and only on one machine. It is fast, simple, and local. The trade-off is that the server has the user's privileges and can access the same files the user can access, unless the host adds restrictions.

### Message Framing

The bytes in stdin and stdout are just a stream. How does the reader know where one message ends? MCP uses a simple rule: **one JSON message per line**. Each message is written on one line and ends with a newline character. The JSON itself must not contain a raw newline inside it.

```js runnable title="Read messages from a stdio stream"
// stdio: the server writes one JSON message per line to its standard output.
// Logs must go to standard error, never to standard output.
const stdout =
  [
    '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"count_words"}],"resultType":"complete"}}',
    '{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":2,"progress":1,"total":2}}',
    '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"4"}],"resultType":"complete"}}',
  ].join("\n") + "\n";

for (const line of stdout.split("\n")) {
  if (!line) {
    continue;
  }
  const msg = JSON.parse(line);
  console.log(
    msg.id !== undefined ? `answer to id ${msg.id}` : `notification ${msg.method}`,
  );
}
```

Expected output:

```text
answer to id 1
notification notifications/progress
answer to id 2
```

Because stdout carries only MCP messages, the server must **never** print anything else there. Logs, debug lines, and startup messages belong on **stderr**. The host may show them, save them, or ignore them.

```js runnable title="One stray log line breaks the stream"
// What goes wrong if a server prints a log line to standard output.
const stdout = [
  '{"jsonrpc":"2.0","id":1,"result":{"tools":[],"resultType":"complete"}}',
  "Server started, waiting for requests",
  '{"jsonrpc":"2.0","id":2,"result":{"content":[],"resultType":"complete"}}',
];

for (const line of stdout) {
  try {
    const msg = JSON.parse(line);
    console.log("ok:   id", msg.id);
  } catch {
    console.log("FAIL: not JSON ->", JSON.stringify(line));
  }
}
```

Expected output:

```text
ok:   id 1
FAIL: not JSON -> "Server started, waiting for requests"
ok:   id 2
```

This is why host developers and server authors treat stdout as a strict message channel. A single stray log line breaks the stream and makes the host think it received invalid JSON.

### Process Lifecycle

The host controls the server process:

1. **Start:** the host runs the command from its settings, such as `node server.js`, often with **environment variables** that hold settings and keys.
2. **Run:** the two sides exchange messages.
3. **Stop:** the host closes the server's stdin. A good server notices and exits. If it does not exit quickly, the host asks the operating system to stop it, and may force it to stop.

If the server crashes, the host sees the process end and can restart it or tell the user what happened.

SDKs are still catching up with 2026-07-28. In the official TypeScript SDK 2.1.0, a stdio connection still starts with the older <code>initialize</code> step from 2025-11-25, while HTTP uses <code>server/discover</code>. When you test a stdio server, do not be surprised to see <code>initialize</code> in the log.

### Sandboxing

A stdio server runs on your computer **with your rights**. It can read any file you can read, unless something stops it. That is powerful, but it also means a local server can do real damage if you give it access to the wrong files or keys.

So:

- Only install stdio servers you trust, from sources you trust.
- Prefer hosts that run local servers in a **sandbox**: a fenced-off space with limited access to files and the network.
- Give the server only the folders and keys it needs.

A safe local server is easy to use, but it still needs boundaries. The protocol itself does not enforce those boundaries for you; the host, the user, and the system setup do.

## Streamable HTTP

### One Endpoint, One POST per Request

A Streamable HTTP server has a single web address, often ending in `/mcp`. The client sends **each request as its own HTTP `POST`** to that address. The body is the JSON-RPC message.

The client also adds MCP **headers**. These headers copy key facts out of the body so web tools like load balancers and firewalls can read them without parsing the JSON:

```js runnable title="The headers a client sends"
// Streamable HTTP: the headers a 2026-07-28 client sends with each POST.
function headersFor(msg) {
  const h = {
    "content-type": "application/json",
    accept: "application/json, text/event-stream",
    "mcp-protocol-version": "2026-07-28",
    "mcp-method": msg.method,
  };
  // These methods name one thing, so the name is copied into a header too.
  if (["tools/call", "prompts/get"].includes(msg.method)) {
    h["mcp-name"] = msg.params.name;
  }
  if (msg.method === "resources/read") {
    h["mcp-name"] = msg.params.uri;
  }
  return h;
}

const call = {
  jsonrpc: "2.0",
  id: 3,
  method: "tools/call",
  params: { name: "lookup_order", arguments: { order_id: "A-1001" } },
};
const read = {
  jsonrpc: "2.0",
  id: 4,
  method: "resources/read",
  params: { uri: "kb://shipping" },
};
for (const m of [call, read]) {
  console.log(
    Object.entries(headersFor(m))
      .map(([k, v]) => `${k}: ${v}`)
      .join("\n"),
  );
  console.log("---");
}
```

Expected output:

```text
content-type: application/json
accept: application/json, text/event-stream
mcp-protocol-version: 2026-07-28
mcp-method: tools/call
mcp-name: lookup_order
---
content-type: application/json
accept: application/json, text/event-stream
mcp-protocol-version: 2026-07-28
mcp-method: resources/read
mcp-name: kb://shipping
---
```

The server checks that the headers and the body agree. If they do not match, it refuses the request with error `-32020`.

### Two Kinds of Replies

The server can answer a POST in two ways:

- **One JSON reply:** `Content-Type: application/json`. Used when the answer is ready at once.
- **A stream:** `Content-Type: text/event-stream`, using **server-sent events (SSE)**. The server can send progress notifications first and then the final result. The stream closes when the result is complete.

A notification gets neither: the server replies with `202 Accepted` and an empty body.

```js runnable title="Read a streamed reply"
// A streamed HTTP reply (server-sent events). Each event's data line holds one message.
const body = `event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":0,"progress":1,"total":2}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":0,"progress":2,"total":2}}

event: message
data: {"jsonrpc":"2.0","id":0,"result":{"content":[{"type":"text","text":"Report ready"}],"resultType":"complete"}}

`;

const messages = body
  .split("\n\n")
  .map((event) => event.split("\n").find((l) => l.startsWith("data:")))
  .filter(Boolean)
  .map((line) => JSON.parse(line.slice(5)));

for (const m of messages) {
  console.log(m.method ?? `result for id ${m.id}: ${m.result.content[0].text}`);
}
```

Expected output:

```text
notifications/progress
notifications/progress
result for id 0: Report ready
```

In the Inspector, click that call's row. The reply is a list: the progress notifications come first, and the result comes last. They all arrived on one stream.

This is the main advantage of Streamable HTTP for long-running work: the client can see progress without waiting for the final answer.

### No Resumability

Older versions let a client reconnect to a broken stream and pick up where it left off by using an event id. The 2026-07-28 version removed that behavior. If a stream breaks, the client sends the request again. That is one reason tools that change data should be safe to retry. You will meet this idea, called **idempotency**, in Chapter 8.

Older versions also let the client open a long `GET` stream to receive messages from the server at any time. In 2026-07-28, a `GET` request to the endpoint is refused. Clients that want updates instead send a `subscriptions/listen` request. You will see that in the next lesson.

A local HTTP server is reachable by web pages in your browser. A bad page could try to send it requests. Local servers must check the <code>Origin</code> header, should listen only on <code>127.0.0.1</code> (your own computer), and should ask for a token. The official SDK includes helpers for the Origin and Host checks.

### Legacy HTTP+SSE

Before Streamable HTTP, remote MCP used a transport called **HTTP+SSE**. It had two addresses: one long-lived `GET` stream for messages from the server, and a separate `POST` address for messages from the client. Matching responses to requests across two connections was fragile, and it depended on a connection that stayed open.

HTTP+SSE was replaced by Streamable HTTP in 2025 and is **deprecated**. You will still find some older servers that use it. Some clients can talk to both transports.

## Local vs Remote

### Trade-Offs

| | stdio (local) | Streamable HTTP (remote) |
| --- | --- | --- |
| Where it runs | On the user's computer, started by the host | On a server, often in the cloud |
| Who can use it | One user | Many users and many hosts |
| Speed | Very fast: no network | Each call crosses the network |
| Access | The user's files and apps | Company systems and data |
| Security | Runs with the user's rights: needs trust and sandboxing | Needs HTTPS, sign-in, and access checks |
| Updates | Each user installs a new version | Update once, everyone gets it |

```js runnable title="Choose a transport"
// Pick a transport from a few simple facts.
function pickTransport(s) {
  if (s.manyUsers || s.runsInCloud) {
    return "Streamable HTTP";
  }
  if (s.needsLocalFiles && s.oneUser) {
    return "stdio";
  }
  return "either: start with stdio, move to HTTP when others need it";
}

const servers = [
  { name: "Read my project folder", needsLocalFiles: true, oneUser: true },
  { name: "Company help desk", manyUsers: true, runsInCloud: true },
  { name: "Personal note search", oneUser: true },
];
for (const s of servers) {
  console.log(s.name.padEnd(24), "->", pickTransport(s));
}
```

Expected output:

```text
Read my project folder   -> stdio
Company help desk        -> Streamable HTTP
Personal note search     -> either: start with stdio, move to HTTP when others need it
```

Many teams start with stdio while they build a server, then move it to HTTP when other people need it. The SDK code for tools, resources, and prompts stays the same. Only the transport line changes.

## Common mistakes

- **`console.log` in a stdio server.** In Node, `console.log` writes to stdout and breaks the message stream. Use `console.error` for logs.
- **Pretty-printed JSON over stdio.** A JSON message split over several lines cannot be read correctly. Write each message on one line.
- **Forgetting the Accept header.** A client must accept both `application/json` and `text/event-stream`, because the server chooses which one it will use.
- **Expecting a GET stream.** In 2026-07-28, `GET` on the endpoint returns 405. Use `subscriptions/listen`.
- **Treating local as safe.** A local server can still be reached by a bad web page or a bad prompt. Check origins, limit folders, and ask before risky tools.

## Exercises

Each exercise ends with a value on the last line. Press **Check** to compare it with the expected answer.

## What to Remember

- **stdio:** the host starts the server as a child process. It sends one JSON message per line on stdin and stdout. Logs go to **stderr**.
- The host controls the **lifecycle**: start, run, and stop by closing stdin.
- A stdio server runs with **your rights**, so trust and sandboxing matter.
- **Streamable HTTP:** one `POST` per request to one endpoint, with `Mcp-Method`, `Mcp-Name`, and `MCP-Protocol-Version` headers.
- Replies are **JSON** or an **SSE stream**: notifications first, then the result. Notifications get **202**.
- 2026-07-28 has **no resumability** and **no GET stream**. **HTTP+SSE** is deprecated.

Next: **Discovery and Versioning**, how a client learns what a server supports, and what happens when versions do not match.
