Transports
By the Syncfusion DevTutorials team · Last updated
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:
- How stdio works: process lifecycle, message framing, logging, and sandboxing.
- How Streamable HTTP works: POST requests, streamed replies, and MCP headers.
- What the older HTTP+SSE transport was and why it is deprecated.
- 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
Left: stdio pipes messages between the host and a server process on the same computer. Right: Streamable HTTP sends one POST per request, and the reply is JSON or a stream.
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.
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:
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.
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:
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:
- Start: the host runs the command from its settings, such as
node server.js, often with environment variables that hold settings and keys. - Run: the two sides exchange messages.
- 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 initialize step from 2025-11-25, while HTTP uses server/discover. When you test a stdio server, do not be surprised to see initialize 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:
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:
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.
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:
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 Origin header, should listen only on 127.0.0.1 (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 |
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:
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.login a stdio server. In Node,console.logwrites to stdout and breaks the message stream. Useconsole.errorfor 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/jsonandtext/event-stream, because the server chooses which one it will use. - Expecting a GET stream. In 2026-07-28,
GETon the endpoint returns 405. Usesubscriptions/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
POSTper request to one endpoint, withMcp-Method,Mcp-Name, andMCP-Protocol-Versionheaders. - 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.