Table of Contents

Connecting to MCP servers

The other half of TTMSMCPClient is the Servers collection: one entry per MCP server the application can reach, each with its own transport, its own protocol handshake, and its own set of discovered tools. Together they decide what the model is actually able to do. A connection is added at design time, built in code, or restored from a JSON configuration file in the format most MCP hosts already use. Once it is running, its tools appear in Tools, and a call the model makes against one of them is routed back to the connection that owns it — under a policy you control. This guide covers adding connections, the three transports and where each one runs, configuration files, discovery, tool approval, elicitation, and logging.

The server collection

Start here, because everything else in this guide hangs off one collection item. TTMSMCPClientServers is a plain collection published on the client, so double-clicking Servers in the object inspector is a complete way to configure a fixed set of servers. Each TTMSMCPClientServerItem is an independent connection: it has its own Start and Stop, its own IsRunning, its own events, and its own cached lists. Stopping one leaves the others untouched and simply removes its tools from what the model is offered.

Launching a local server over STDIO

Use ttSTDIO — the default — whenever the server is a program on the same machine, which covers almost every MCP server distributed today. The client launches Command with Args and EnvironmentVariables and speaks JSON-RPC over the process's standard input and output:

procedure TForm1.AddReportServer;
var
  Server: TTMSMCPClientServerItem;
begin
  Server := MCPClient.Servers.Add;
  Server.DisplayName := 'Reports';
  Server.TransportType := ttSTDIO;
  Server.Command := 'my-mcp-server.exe';

  { One argument per line - do not pack them into a single string. }
  Server.Args.Add('--root');
  Server.Args.Add('C:\Reports');

  { NAME=VALUE pairs, one per line. }
  Server.EnvironmentVariables.Add('REPORT_LOCALE=en-US');

  Server.OnGetToolsList := DoServerToolsReady;
  Server.OnError := DoServerError;
  Server.Start;
end;

procedure TForm1.AddThirdPartyServer;
var
  Server: TTMSMCPClientServerItem;
begin
  { The same shape as a published MCP configuration entry:
      "command": "npx", "args": ["@playwright/mcp@latest", "--headless"] }
  Server := MCPClient.Servers.Add;
  Server.DisplayName := 'Playwright';
  Server.Command := 'npx';
  Server.Args.Add('@playwright/mcp@latest');
  Server.Args.Add('--headless');
  Server.Start;
end;

procedure TForm1.DoServerToolsReady(Sender: TObject);
var
  Server: TTMSMCPClientServerItem;
begin
  { Start returns before the handshake completes. This fires on the main
    thread once the server has answered tools/list, which is the moment
    MCPClient.Tools holds the tools the model can call. }
  Server := Sender as TTMSMCPClientServerItem;
  ShowStatus(Format('%s %s is ready with %d tool(s).',
    [Server.ServerName, Server.ServerVersion, Server.GetTools.Count]));
  AskButton.Enabled := True;
end;

procedure TForm1.DoServerError(Sender: TObject; AId: TJSONValue;
  const ACode: Integer; const AErrorMessage: string);
begin
  ShowStatus(Format('MCP error %d: %s', [ACode, AErrorMessage]));
end;

Args and EnvironmentVariables are TStrings, one entry per line — a single line holding several space-separated arguments is passed to the process as one argument and usually fails. Environment entries take the NAME=VALUE form. A published third-party configuration maps across directly: its command becomes Command, and each element of its args array becomes one Args line.

Reaching a remote server over SSE or streamable HTTP

Choose these when the server is a service you connect to rather than a process you launch. ttHTTP is the current streamable HTTP transport and the one to prefer; if the endpoint turns out not to support it, the connection reports that and falls back to ttSSE by itself, so pointing a ttHTTP entry at an older server still works:

procedure TForm1.AddRemoteServers;
var
  Server: TTMSMCPClientServerItem;
begin
  { Streamable HTTP. If the endpoint turns out not to support it, the
    connection falls back to SSE on its own. }
  Server := MCPClient.Servers.Add;
  Server.DisplayName := 'Inventory';
  Server.TransportType := ttHTTP;
  Server.URL := 'https://mcp.example.com/mcp';
  Server.OnBeforeHTTPRequest := DoBeforeHTTPRequest;
  Server.OnHTTPResponse := DoHTTPResponse;
  Server.Start;

  { An older endpoint that only speaks HTTP with server-sent events. }
  Server := MCPClient.Servers.Add;
  Server.DisplayName := 'Legacy inventory';
  Server.TransportType := ttSSE;
  Server.URL := 'https://mcp.example.com/sse';
  Server.Start;
end;

procedure TForm1.DoBeforeHTTPRequest(Sender: TObject; ACustomHeaders: TStrings);
begin
  { Added to every request this connection sends. }
  ACustomHeaders.Add('X-Tenant-Id: ' + FTenantId);
end;

procedure TForm1.DoHTTPResponse(Sender: TObject; AResponseCode: Integer;
  AResponseText: string; AHeaders: TStrings; var AProcessResponse: Boolean);
begin
  { Leave AProcessResponse True to let the connection parse the payload as
    usual; set it to False to swallow a response handled here. }
  if AResponseCode >= 400 then
    ShowStatus(Format('HTTP %d from %s', [AResponseCode, AResponseText]));
end;
TransportType Reaches Properties in use
ttSTDIO A local process the client launches. Command, Args, EnvironmentVariables
ttSSE An HTTP endpoint using server-sent events. URL
ttHTTP A streamable HTTP endpoint, falling back to SSE. URL

OnBeforeHTTPRequest hands you the header list before every request on that connection, which is where a tenant id, a correlation id, or a bearer token belongs. OnHTTPResponse sees the status code and headers of each reply and can suppress the connection's own handling by clearing AProcessResponse. Both are per connection, so different endpoints can carry different headers.

OpenSSL

Both remote transports are built on Indy and reach a secure endpoint through OpenSSL, so the library has to be deployable with the application:

Platform OpenSSL required
iOS Always — the transports link the static OpenSSL headers on ARM builds whether or not the URL is https.
Everything else Only when the server URL is https.

ttSTDIO needs none of this: it talks to a child process over pipes, not over a socket.

Platform support

Check this before promising an agent on a non-Windows target. Since 1.0.2.0 the STDIO transport launches and supervises a child process on macOS and Linux as well as Windows, so a desktop application can host local MCP servers on all three:

Platform ttSTDIO ttSSE and ttHTTP
Windows Yes Yes
macOS (desktop) Yes Yes
Linux Yes Yes
iOS, Android No — no child processes Yes

On mobile there is no process to launch, so an agent there has to reach its servers over SSE or streamable HTTP. That is worth knowing at design time: a Servers collection built around ttSTDIO entries does not port to a phone without being reconfigured.

Storing the server list in a configuration file

Reach for this as soon as users are allowed to add servers, because the collection then has to outlive the executable. LoadFromJSONFile and SaveToJSONFile read and write the same JSON array most MCP hosts use — an array of objects with name, type, and either command/args/env or url:

[
    {
        "name": "mcp-stdio-example",
        "type": "stdio",
        "command": "path-to-my-server.exe",
        "args": [
            "-arg1",
            "-arg2"
        ],
        "env": [
            "MYVAR=myvalue"
        ]
    },
    {
        "name": "mcp-sse-example",
        "type": "sse",
        "url": "http://my-mcp.server/sse"
    },
    {
        "name": "mcp-streamable-http-example",
        "type": "http",
        "url": "http://my-mcp.server/mcp"
    }
]

type takes stdio, sse, or http, matching the three TransportType values, and only the fields that transport uses are read or written.

procedure TForm1.LoadServerConfiguration;
begin
  { LoadFromJSONFile stops every current connection, clears the
    collection, rebuilds it from the file, and starts each entry it
    created - so do not call Start yourself afterwards. }
  FConfigFileName := ChangeFileExt(ParamStr(0), '-config.json');

  if TFile.Exists(FConfigFileName) then
    MCPClient.Servers.LoadFromJSONFile(FConfigFileName);
end;

procedure TForm1.SaveServerConfiguration;
begin
  { Writes name, type, and the transport-specific fields: command, args
    and env for STDIO, url for SSE and HTTP. }
  MCPClient.Servers.SaveToJSONFile(FConfigFileName);
end;

procedure TForm1.LoadServerConfigurationAsUTF16;
begin
  { Both methods have an overload that takes the encoding explicitly;
    without it they use UTF-8. }
  MCPClient.Servers.LoadFromJSONFile(FConfigFileName, TEncoding.Unicode);
end;

LoadFromJSONFile is not additive. It stops every current connection, clears the collection, rebuilds it from the file, and starts each entry it created — so calling Start in a loop afterwards is redundant, and any handler you had assigned to an individual item is gone with the item. Both methods have an overload taking a TEncoding; without one they use UTF-8.

What a connection discovered

Consult this when the model does not seem to know about a tool you expected. After the handshake, the connection records what the server declared and fetches the lists it advertised, exposing them through ServerName, ServerVersion, the HasTools, HasResources, HasPrompts and HasLogging flags, and the GetTools, GetResources and GetPrompts accessors:

procedure TForm1.ListServerTools;
var
  I, J: Integer;
  Server: TTMSMCPClientServerItem;
  Tools: TJSONArray;
  ToolName: string;
begin
  for I := 0 to MCPClient.Servers.Count - 1 do
  begin
    Server := MCPClient.Servers[I];
    if not Server.IsRunning then
      Continue;

    { ServerName and ServerVersion are what the server reported during
      initialize; the Has... flags are the capabilities it declared. }
    MemoTools.Lines.Add(Format('%s %s (tools: %s, resources: %s, prompts: %s)',
      [Server.ServerName, Server.ServerVersion,
       BoolToStr(Server.HasTools, True),
       BoolToStr(Server.HasResources, True),
       BoolToStr(Server.HasPrompts, True)]));

    { GetTools, GetResources and GetPrompts return the connection's own
      arrays. Read them; never free one and never hand one to something
      that takes ownership. }
    Tools := Server.GetTools;
    for J := 0 to Tools.Count - 1 do
      if Tools.Items[J].TryGetValue<string>('name', ToolName) then
        MemoTools.Lines.Add('  ' + ToolName);
  end;
end;

procedure TForm1.ReconnectServer(AIndex: Integer);
begin
  { Stop clears the cached tool, resource and prompt lists; Start runs the
    handshake again and rediscovers them. }
  MCPClient.Servers[AIndex].Stop;
  MCPClient.Servers[AIndex].Start;
end;

Those three accessors return the connection's own arrays, not copies. Read them, and never free one or hand one to anything that takes ownership — the connection frees them itself when it is destroyed, and a double free here takes the whole client with it.

Start is asynchronous. It returns once the transport is up, while the initialize exchange and the tools/list request that follows it are still in flight. OnGetToolsList fires on the main thread when that list arrives, and it is the earliest safe moment to query — before it, Tools is still empty and the model is told about nothing.

Routing and approving tool calls

This is the part that decides how much the agent may do on its own. When the model asks for a tool, the client finds the connection that owns it, sends a tools/call, waits for the result, and feeds it back to the model — but only after the call has been approved:

procedure TForm1.ConfigureToolApproval;
begin
  { tcmAsk - the default - hands OnBeforeUseTool an AAllow of False, so a
    call happens only if the handler says yes. tcmAllow starts from True
    and the handler can still veto. With no handler assigned, tcmAsk
    refuses every call. }
  MCPClient.ToolCallMode := tcmAsk;

  { How long the client waits for the server to answer one tools/call, in
    milliseconds. The default is 60000. }
  MCPClient.ToolCallTimeout := 120000;

  { Sent back to the model in place of a result when a call is refused.
    Leave it empty to return nothing at all. }
  MCPClient.ToolDeniedMessage :=
    'The user declined this tool. Answer from what you already know ' +
    'and say that the data could not be read.';

  MCPClient.OnBeforeUseTool := DoBeforeUseTool;
end;

procedure TForm1.DoBeforeUseTool(Sender: TObject; AToolName: string;
  AParam: TJSONObject; var AAllow: Boolean);
begin
  { Runs on the main thread, so a modal confirmation is allowed here.
    AParam holds the arguments the model chose - show them, because that
    is the part the user is actually approving. }
  if AToolName.StartsWith('read_') or AToolName.StartsWith('list_') then
    AAllow := True
  else
    { ConfirmToolCall is your own confirmation dialog. }
    AAllow := ConfirmToolCall(AToolName, AParam.ToJSON);
end;
Property Purpose
ToolCallMode tcmAsk (default) starts each call refused; tcmAllow starts it permitted.
OnBeforeUseTool Gets the tool name, the model's arguments, and a var AAllow it can set either way.
ToolCallTimeout Milliseconds to wait for one tools/call result. Default 60000.
ToolDeniedMessage Returned to the model in place of a result when a call is refused.

tcmAsk with no OnBeforeUseTool handler refuses every call silently, which looks exactly like a model that ignores its tools — so the two properties belong together. OnBeforeUseTool runs on the main thread, so a modal confirmation is legitimate there. The same thread is blocked for the duration of each tool round trip, though, so show a waiting indicator before Execute and keep ToolCallTimeout realistic for the slowest server in the collection. ToolDeniedMessage is worth setting: without it a refused call returns nothing and the model tends to retry the same tool, whereas a sentence explaining the refusal lets it answer from what it already has.

Answering a server question

Some servers need input mid-request — a missing parameter, a confirmation, a URL the user has to visit. That is elicitation, and the client answers it only on connections that declared they would:

procedure TForm1.EnableElicitation(AServer: TTMSMCPClientServerItem);
begin
  { Both flags default to False, and a server request in a mode that was
    not declared is refused with an invalid-parameter error before the
    handler runs. }
  AServer.Capabilities.Elicitation.Form := True;
  AServer.Capabilities.Elicitation.URL := True;
  AServer.OnElicitationCreate := DoElicitationCreate;
end;

procedure TForm1.DoElicitationCreate(Sender: TObject; AParams: TJSONObject;
  var AUserResponse: TTMSMCPClientServerUserResponse;
  var AResponseContent: TJSONObject);
var
  Message, Mode: string;
begin
  AParams.TryGetValue<string>('message', Message);
  AParams.TryGetValue<string>('mode', Mode);

  { urAccept, urDecline and urCancel map to the three protocol actions.
    Anything other than urAccept sends no content back. }
  if not AskUserToProceed(Message) then
  begin
    AUserResponse := urDecline;
    Exit;
  end;

  AUserResponse := urAccept;

  { In url mode the connection opens the URL itself once you accept. In
    form mode, add the answers to the object you were handed - it is
    already created and its ownership passes to the reply, so do not
    replace it and do not free it. }
  if Mode = 'form' then
    AResponseContent.AddPair('department', DepartmentEdit.Text);
end;

Capabilities.Elicitation.Form and .URL both default to False, and a request arriving in a mode that was not declared is refused with an invalid-parameter error before OnElicitationCreate ever runs. In the handler, AUserResponse carries the outcome — urAccept, urDecline, or urCancel — and only urAccept sends content back. For url mode the connection opens the address itself once you accept. For form mode, add your values to the AResponseContent object you were handed: it already exists and its ownership passes to the reply, so replacing it leaks and freeing it crashes.

Logging and diagnostics

Turn this on the first time a connection misbehaves, because almost everything interesting happens off-screen. Logging and LogFileName, added in 1.0.1.0, write a timestamped, level-tagged line for every connection event, request, and tool call:

procedure TForm1.ConfigureLogging;
begin
  { LogFileName with no path separator is written to the user's documents
    folder; give a full path to place it yourself. Entries are appended,
    so the file grows across runs. }
  MCPClient.LogFileName := 'mcpclient.log';

  { Logging gates the file only. Setting either property also forwards it
    to the internal LLM component, so the model traffic lands in the same
    file. }
  MCPClient.Logging := True;

  { OnLog fires whether or not Logging is on, and always on the main
    thread - writing straight into a memo is safe. }
  MCPClient.OnLog := DoClientLog;
end;

procedure TForm1.DoClientLog(Sender: TObject; AServer: TTMSMCPClientServerItem;
  ATimeStamp: TDateTime; ALevel: TTMSMCPLoggingLevel; AMessage: string);
var
  Origin: string;
begin
  { AServer is nil for entries raised by the client itself, and is the
    connection that produced the entry otherwise. }
  if Assigned(AServer) and (AServer.ServerName <> '') then
    Origin := ' (' + AServer.ServerName + ')'
  else
    Origin := '';

  if ALevel >= llWarning then
    MemoLog.Lines.Add(Format('[%s] %s: %s%s',
      [GetEnumName(TypeInfo(TTMSMCPLoggingLevel), Ord(ALevel)),
       DateTimeToStr(ATimeStamp), AMessage, Origin]));
end;

LogFileName without a path separator is written into the user's documents folder; give a full path to place it yourself. Entries are appended, so the file spans runs. Setting either property also forwards it to the internal LLM component, so the model traffic and the MCP traffic land in the same file in order — which is exactly what is needed to see whether a missing answer was the model never asking for a tool or the tool never replying.

OnLog is the in-application mirror of the same stream, carrying the originating connection (or nil for the client's own entries), the timestamp, a TTMSMCPLoggingLevel, and the text. It fires whether or not Logging is on, and always on the main thread, so a memo can be written directly from the handler. OnServerLog is a different stream: it carries log notifications the server pushed over the protocol rather than the client's own diagnostics.

Putting it together

A working agent sets its policy, restores its connections, and waits for discovery before it lets anyone ask anything. The order is deliberate — logging first so the connection attempts themselves are recorded, then the approval policy, then the servers, then the readiness signal:

procedure TForm1.StartAgent;
var
  I: Integer;
begin
  { 1. Logging first, so the connection attempts themselves are recorded. }
  MCPClient.LogFileName := 'mcpclient.log';
  MCPClient.Logging := True;
  MCPClient.OnLog := DoClientLog;

  { 2. Tool routing policy. }
  MCPClient.ToolCallMode := tcmAsk;
  MCPClient.ToolCallTimeout := 120000;
  MCPClient.ToolDeniedMessage := 'The user declined this tool.';
  MCPClient.OnBeforeUseTool := DoBeforeUseTool;

  { 3. The server list, restored from the JSON configuration. Every entry
    it creates is already started when the call returns. }
  FConfigFileName := ChangeFileExt(ParamStr(0), '-config.json');
  if TFile.Exists(FConfigFileName) then
    MCPClient.Servers.LoadFromJSONFile(FConfigFileName);

  { 4. Wait for discovery rather than querying straight away. }
  AskButton.Enabled := False;
  for I := 0 to MCPClient.Servers.Count - 1 do
    MCPClient.Servers[I].OnGetToolsList := DoServerToolsReady;
end;

procedure TForm1.DoServerToolsReady(Sender: TObject);
begin
  AskButton.Enabled := MCPClient.Tools.Count > 0;
end;

Reattaching OnGetToolsList after LoadFromJSONFile is the step that is easy to lose: the load cleared the collection, so the handlers went with the old items, and the entries it created are already starting by the time the call returns.

Common mistakes

  • Querying immediately after Start. Discovery is asynchronous. Wait for OnGetToolsList, or the model is told about no tools at all.
  • Freeing the array from GetTools. It belongs to the connection. The same applies to GetResources and GetPrompts.
  • Packing several arguments into one Args line. Each line is one argument.
  • Leaving ToolCallMode at tcmAsk with no OnBeforeUseTool. Every tool call is refused, silently.
  • Calling Start after LoadFromJSONFile. The load already started every entry; what actually needs redoing is the per-item event handlers.
  • Assuming ttSTDIO works everywhere. There is no child process on iOS or Android — use ttSSE or ttHTTP there.
  • Setting ToolCallTimeout too low for a slow server. The call is abandoned, the model receives nothing, and the log shows a timeout that reads like a server fault.

See also