Table of Contents

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. CurrentSessionState returns nil and SessionCount returns nothing useful.
  • Calling Run from a GUI thread. It blocks. In a GUI application Start is 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 nil as owner is yours: Stop it before you free it, and free it after the server that used it.
  • Leaving OnError unassigned. The transport has nowhere to report what it caught, so every internal failure is invisible.

See also