The specification says it plainly: protocol semantics are identical on every transport. A transport is a binding: it defines how messages are framed and delivered, how request metadata is carried, how a client cancels an in-flight request, and how a connection ends. Messages are JSON-RPC, encoded as UTF-8. Over any transport, clients send requests and notifications; servers send responses and notifications. In the new revision, servers do not send requests of their own and clients do not send responses; the cases that used to need that are handled differently (module 4).
Two bindings are standard: stdio and Streamable HTTP. You may also write a custom transport, as long as you keep the JSON-RPC format, the message patterns and the per-request metadata model, and document how connections start, how messages are framed, and how cancellation works. The specification adds a useful rule: a custom transport over a reliable byte stream (a Unix socket, a TCP connection) should reuse the stdio framing, which is just newline-delimited JSON, instead of inventing a new one.
The two standard bindings, and which one to pick
stdio
The client launches the server as a subprocess. The server reads messages from stdin and writes them to stdout, one message per line. Because the line is the frame, a message must not contain a newline (compact JSON never does, because newlines inside strings are escaped). The server may write logs to stderr, and the client may capture, forward or ignore them, but must not treat stderr output as an error. The server must not write anything to stdout that is not a valid MCP message, and the client must not write anything to stdin that is not one.
The three ways stdio goes wrong
1. Something else writes to stdout. This is the classic mistake. In the example, the server has a tool called chatty whose author left a print("DEBUG: looking things up...") in it. The call still succeeds, but the client sees an extra line that is not JSON:
What the client saw
Result
the tool's result
arrived, correct
DEBUG: looking things up...
not a message; my client records it as a protocol violation and carries on
a naive json.loads on every line
raises, and a client written that way dies on a bug that is in the server
The fix is in the server: send logs to stderr (or a file) and never print to stdout. A client should treat unparsable lines as a diagnostic, not a crash.
2. The server dies. In the example a crash tool exits the process mid-call. The in-flight request is lost, because nothing is left to answer it; the client sees the pipe close and fails the pending request with a connection error. The specification says the client should restart the server. Because the new protocol keeps no state on the server, the restarted process can simply be sent the next request, and a read-only request can be retried. After the restart the example's retry of search works, and the process was started 2 times. A subscriptions/listen stream does not survive a restart: the client must open it again.
3. The server will not stop. The specification's shutdown order is: close the server's stdin, wait, and only if it does not exit, terminate it (on POSIX, SIGTERM, then SIGKILL; on Windows, TerminateProcess or a job object). A server should exit promptly when its stdin reaches end of file, since that is "the only portable" signal. The example has three kinds of server and the client's ladder gives the three results:
Server
How it ended
Time (grace 0.3 s)
exits on end of input
exited
about 0.01 s
ignores end of input
terminated
about 0.3 s
ignores end of input and SIGTERM
killed
about 0.6 s
Cancellation on stdio
stdio has one shared channel, so there is no per-request stream to close. The client sends a notifications/cancelled message naming the request id, and the server should stop and must not send further messages for that request. In the example, a slow report planned for 40 steps was cancelled after 0.3 s; the server stopped after 6 steps, wrote a note to stderr, and no response ever arrived. The client also forgets the id, so a response that crossed the cancel on the wire would be ignored. A client should also set timeouts, and on timeout do the same thing: cancel, and stop waiting.
Tip: tools must check for cancellation
The protocol only tells the server to stop. A tool that loops for ten minutes and never looks at the cancel flag keeps working. In the example, slow_report checks ctx.cancelled at every step. Long tools should do the same, and should report progress, because progress is also how a client knows work is still happening before it gives up.
Which era is on the other end? (the probe)
The same server program might be old or new. On stdio the specification says a client that supports both should probe with server/discover before anything else, with its preferred modern version in _meta. There are three outcomes:
The server returns a result: it is modern; choose a mutually supported version.
The server returns a recognised modern error (such as the unsupported-version error): it is modern but wants another version. Do not fall back to initialize; use a version from its supported list.
Any other error, or silence past a timeout: the server is legacy. Fall back to initialize.
The fallback must not be keyed to one error code: legacy servers answer an unknown pre-initialize request with whatever they like (commonly -32601 or -32602) or not at all. The example has a legacy server that stays silent until initialised; the client waits the timeout, then falls back, and the whole connect takes about 0.5 s instead of 0.04 s. Even a client that only speaks the new form should still probe, because some old servers do not check that initialize came first and would run tools/call under old rules.