Choosing a transport
The transport is the first architectural decision in an MCP server, and the hardest to change later. It decides where the server can run, how many clients it can serve, whether per-client sessions exist at all, and what securing it involves — all of which the rest of your design sits on top of. The protocol itself is identical in every case, so this is not a feature choice but a deployment one: is the server a process a client launches, or a service a client connects to? This guide answers that, then covers the two local transports in detail and the matching client side.
The decision
| STDIO | Named Pipe | SSE | Streamable HTTP | |
|---|---|---|---|---|
| Server is | Launched by the client | Already running | Already running | Already running |
| Reach | Same machine, same process tree | Local, or a named machine | Network | Network |
| Clients at once | One | One at a time | Several | Several |
| Sessions | No | No | Limited | Yes |
| Transport security | Inherited from the launcher | Windows pipe ACLs | TLS | TLS, CORS, OAuth 2.1 |
| Configuration needed | None | A pipe name | Port, endpoints | Port, endpoint, certificate |
Two questions settle it almost every time. Does the client start the server? If yes, STDIO — no port, no certificate, nothing to configure. Does the server need to serve more than one client, or carry per-client state? If yes, Streamable HTTP, because it is the only one with real sessions.
Named Pipe fills the remaining case: a desktop application that is already running, exposed to a local client, without opening a network port.
Standard I/O
STDIO is the default. A server with no Transport assigned uses it, which is
why the minimal example in Getting started has no
transport code at all.
The model is simple: the client launches your executable and speaks JSON-RPC over its standard input and output. That makes it the natural fit for a desktop AI assistant configured to run your program, and it inherits the launching user's identity, so there is no separate authentication story.
Two consequences follow, and both matter. Anything your program writes to
standard output is protocol traffic. A stray WriteLn corrupts the stream, so
diagnostics must go to standard error or a file. And there is exactly one
client, for the process lifetime — no sessions, and the session APIs on the
server return empty values.
Run blocks until standard input closes, which is the normal way a STDIO server
ends: the client exits, the pipe closes, the loop returns.
Named pipes
A named pipe suits a long-running desktop application that should be reachable without a network port. The client connects by pipe name rather than launching anything, so the application keeps running between connections and can expose live in-process state — the document currently open, the current selection, the editor's contents.
procedure TForm1.StartPipeServer;
var
Transport: TTMSMCPNamedPipeTransport;
begin
Transport := TTMSMCPNamedPipeTransport.Create(Server);
Transport.PipeName := 'MCPServer';
Transport.DefaultTimeout := 5000;
{ Leave ServerName empty for the local machine; set it to reach a pipe
published by another machine. }
Server.Transport := Transport;
Server.Start;
end;
procedure TForm1.StopPipeServer;
begin
Server.Stop;
end;
PipeName is what the client asks for. ServerName is empty for the local
machine, or the name of another machine for a remote pipe. DefaultTimeout
bounds how long an operation waits, and Connected reports whether a client is
attached.
Because most MCP clients speak STDIO rather than named pipes, a small bridge executable is the usual companion: it is launched over STDIO by the client and forwards to the pipe. That keeps the application itself free of the client's process lifetime.
Named pipes are Windows-only, and access is governed by pipe ACLs rather than by anything in the protocol.
The client side
Each server transport has a counterpart for applications using MCP rather than
serving it, all descending from
TTMSMCPClientTransport:
| Class | Connects to |
|---|---|
TTMSMCPClientTransportSTDIO |
A server process it launches itself. |
TTMSMCPClientTransportHTTP |
A Streamable HTTP endpoint. |
TTMSMCPClientTransportSSE |
An SSE endpoint. |
In most applications you do not create these directly —
TTMSMCPClient selects one from the server entry's
transport type. Reach for them explicitly only when you are driving the protocol
yourself.
Writing a custom transport
TTMSMCPTransport is abstract, so a transport of your own is a descendant that
overrides Start, Stop, ProcessMessages, and Run. Inbound messages go to
DispatchMessage(SessionId, Body), which returns the response to send back. If
the transport can tell clients apart, call NotifySessionCreated and
NotifySessionRemoved as connections come and go, and override
ActiveSessionIds, SessionCount, SendNotificationToSession, and
BroadcastNotification so server-side session features work over it.
That is the whole contract. Anything that can carry bytes both ways — a message queue, a serial link, an embedded broker — can host MCP.
Combining a transport choice with server capabilities
Transport and server configuration are one decision, not two: the transport decides whether sessions exist, and the session-dependent server capabilities are only meaningful where they do. Because the tool and resource registrations are identical either way, a single server can be built for both and the transport chosen at startup:
procedure TServerApp.StartWithConfiguredTransport(const AMode: string);
var
Stdio: TTMSMCPStdioTransport;
Pipe: TTMSMCPNamedPipeTransport;
begin
{ One server, one of several transports, chosen at startup. The tools and
resources registered on the server are identical either way - only the
reach and the session model change. }
if SameText(AMode, 'pipe') then
begin
Pipe := TTMSMCPNamedPipeTransport.Create(Server);
Pipe.PipeName := 'MCPServer';
Pipe.DefaultTimeout := 5000;
Server.Transport := Pipe;
{ Single client, no sessions: broadcast is the only notification that
makes sense here. }
Server.EnableToolNotifications := True;
Server.Start;
end
else
begin
Stdio := TTMSMCPStdioTransport.Create(Server);
Server.Transport := Stdio;
{ Under STDIO every byte on stdout is protocol traffic, so diagnostics
must not go there. }
Server.OnLog := LogToFile;
Server.Start;
{ Blocks until stdin closes - correct for a console host, never for a
GUI thread. }
Stdio.Run;
end;
end;
Only three things differ between the branches, and each is a consequence of the
transport rather than of the server: how it is reached, where diagnostics may
go, and whether the process blocks in Run. A named-pipe server is
single-client, so per-session resources and targeted notifications have nowhere
to go — broadcast notifications are the whole of it, and that branch pairs
naturally with the broadcast calls in
Configuring the server. Move
the same server to Streamable HTTP and the session APIs
described in Sessions and state
come alive without touching a line of tool code. Choose the transport first, then
enable the capabilities it can actually carry.
Errors, logging, and diagnostics
Every transport reports failures through one channel. OnError, declared on
TTMSMCPTransport as a TProc<Exception>, fires from the protected LogError
whenever a transport catches something internally — a pipe that dropped, a port
it could not bind, a request it could not parse. Nothing is written anywhere by
default, so a transport with no OnError handler fails silently, which is the
most common reason a server appears to start and then simply never answers.
The two HTTP transports add OnLog, a
procedure(const Msg: string) of object reporting connection and request
activity. It is a plain method rather than an anonymous method, and it is not
the same event as TTMSMCPServer.OnLog, which carries (Sender, LogMessage) —
the two are easy to confuse by name.
procedure TServerApp.StartWithDiagnostics;
var
Transport: TTMSMCPStreamableHTTPTransport;
begin
Transport := TTMSMCPStreamableHTTPTransport.Create(Server);
Transport.Port := 8934;
Transport.MCPEndpoint := '/mcp';
{ OnError is declared on the base TTMSMCPTransport as TProc<Exception>, so
every transport has it. Without a handler the transport's own failures go
nowhere at all. }
Transport.OnError :=
procedure(E: Exception)
begin
WriteLog('transport error: ' + E.ClassName + ': ' + E.Message);
end;
{ OnLog is specific to the two HTTP transports and reports connection and
request activity. It is a plain method, not an anonymous method. }
Transport.OnLog := LogTransportMessage;
Server.Transport := Transport;
Server.Start;
{ Running is the third diagnostic: after Start returns it says whether the
transport actually came up. }
if not Transport.Running then
WriteLog('transport did not start on port ' + IntToStr(Transport.Port));
end;
procedure TServerApp.LogTransportMessage(const Msg: string);
begin
WriteLog(FormatDateTime('hh:nn:ss.zzz', Now) + ' - ' + Msg);
end;
procedure TServerApp.WriteLog(const ALine: string);
begin
{ Never WriteLn under STDIO - standard output is protocol traffic there.
A file (or stderr) is the safe destination for every transport. }
TFile.AppendAllText(FLogFileName, ALine + sLineBreak);
end;
Where the log goes matters as much as having one. Under STDIO standard output is
protocol traffic, so diagnostics belong on standard error or in a file; a Windows
service has no console at all. Overriding the protected LogError in a custom
transport is the third option, and the one to reach for when the transport
itself should classify what it caught before handing it on.
| Symptom | Usual cause |
|---|---|
| The server starts and never answers, with no error anywhere | No OnError handler, so whatever the transport caught went nowhere. |
| Named pipe: the client never finds the server | Pipe name mismatch, or a remote pipe without ServerName set and the ACLs to permit it. |
| Named pipe: connection refused while the server is up | All pipe instances busy (ERROR_PIPE_BUSY, 231). Retry — instances are per-connection. |
| Named pipe: writes fail mid-conversation | The other end went away (ERROR_PIPE_NOT_CONNECTED, 233). Reconnect rather than retry the write. |
HTTP: Start fails with an access or binding error |
The port is taken, or there is no URL ACL reservation for it. See Authentication. |
HTTP: a browser client gets 403 {"error": "Invalid origin"} |
Origin checking. The MCP endpoint accepts only an absent Origin header or one naming localhost / 127.0.0.1; there is no property to widen it, so a browser on another origin must be fronted by a proxy. |
| HTTP: TLS never comes up on Windows | The certificate is not bound to the port by thumbprint. |
| The client reports corrupt JSON on STDIO | Something in the process wrote to standard output. |
Common mistakes
- Writing to standard output under STDIO. Every byte on stdout is protocol. Send logs to stderr or a file, or the client sees corrupt JSON.
- Expecting sessions on STDIO or Named Pipe. There are none.
CurrentSessionStatereturnsnilandSessionCountreturns nothing useful. - Calling
Runfrom a GUI thread. It blocks. In a GUI applicationStartis normally enough, because the networked transports pump on their own threads. - Assuming a named pipe is reachable from anywhere. It is Windows-only, and a remote pipe needs the machine named plus the ACLs to permit it.
- Choosing SSE for new work. Streamable HTTP is the current HTTP transport. SSE exists for clients that expect the earlier two-endpoint shape.
- Freeing a transport you gave an owner. Created with the server as owner, it
is freed with the server. A transport created with
nilas owner is yours:Stopit before you free it, and free it after the server that used it. - Leaving
OnErrorunassigned. The transport has nowhere to report what it caught, so every internal failure is invisible.
See also
- HTTP transports — ports, TLS, sessions, CORS, and auth
- MCP Server — what the transport carries
- MCP Client — the other end
TTMSMCPTransport,TTMSMCPStdioTransport,TTMSMCPNamedPipeTransport