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 forOnGetToolsList, or the model is told about no tools at all. - Freeing the array from
GetTools. It belongs to the connection. The same applies toGetResourcesandGetPrompts. - Packing several arguments into one
Argsline. Each line is one argument. - Leaving
ToolCallModeattcmAskwith noOnBeforeUseTool. Every tool call is refused, silently. - Calling
StartafterLoadFromJSONFile. The load already started every entry; what actually needs redoing is the per-item event handlers. - Assuming
ttSTDIOworks everywhere. There is no child process on iOS or Android — usettSSEorttHTTPthere. - Setting
ToolCallTimeouttoo 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
- Querying a language model — the other half of the exchange
- The settings and tools dialogs — letting the user edit this collection
- MCP Server — writing the server this client connects to
- MCP Transports — both ends of each pipe
TTMSMCPClientServerItem,TTMSMCPClientServers,TTMSMCPClientCapabilities