The Model Context Protocol (MCP) is how AI assistants such as Claude Desktop and Claude Code are given new tools. An MCP server tells the assistant which tools it has and what each one needs; the assistant decides when to use them, calls them, and reads the results.
The simplest kind of server is a console program. The assistant starts it, writes requests to its standard input and reads replies from its standard output. That is something Delphi has always done well, and it means your existing Delphi code - business rules, database access, anything Windows-specific - can be put in front of Claude without rewriting it in another language.
This article builds a complete server in one Delphi program. It has two tools: one that looks up Delphi releases by name or version number, and one that lists the Delphi versions installed on the machine by reading the registry - something the assistant cannot easily find out by itself.
Messages are JSON-RPC 2.0, one JSON object per line, in UTF-8. A request has an
id and expects a reply with the same id; a notification has no
id and gets no reply. A session starts like this (from a test run of the program
below, lines shortened):
> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",...}}
< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},
"serverInfo":{"name":"delphi-versions","version":"1.0.0"}}}
> {"jsonrpc":"2.0","method":"notifications/initialized"}
> {"jsonrpc":"2.0","id":2,"method":"tools/list"}
< {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"delphi_version","description":...}]}}
> {"jsonrpc":"2.0","id":"a","method":"tools/call","params":{"name":"delphi_version",
"arguments":{"query":"athens"}}}
< {"jsonrpc":"2.0","id":"a","result":{"content":[{"type":"text",
"text":"Delphi 12 Athens: BDS 23.0, VER360, CompilerVersion 36.0"}],"isError":false}}
Note the id of "a": an id can be a string or a number, so the
server copies it back exactly as it came rather than reading it as an integer.
program Delphi_Mcp;
// A minimal MCP server over stdio. Claude starts the program, writes one
// JSON-RPC message per line to its standard input and reads one reply per
// line from its standard output. Anything else - logging, diagnostics -
// must go to standard error, or the client sees a corrupt message.
{$APPTYPE CONSOLE}
uses
Winapi.Windows,
System.SysUtils,
System.Classes,
System.JSON,
System.Win.Registry;
const
Server_Name = 'delphi-versions';
Server_Version = '1.0.0';
Latest_Protocol = '2025-11-25';
Supported_Protocols: array [0..2] of string = ('2025-11-25', '2025-06-18', '2025-03-26');
Method_Not_Found = -32601;
Invalid_Params = -32602;
type
TDelphi_Release = record
Name: string;
BDS: string;
Define: string;
Compiler: string;
end;
const
Releases: array [0..15] of TDelphi_Release = (
(Name: 'Delphi XE'; BDS: '8.0'; Define: 'VER220'; Compiler: '22.0'),
(Name: 'Delphi XE2'; BDS: '9.0'; Define: 'VER230'; Compiler: '23.0'),
(Name: 'Delphi XE3'; BDS: '10.0'; Define: 'VER240'; Compiler: '24.0'),
(Name: 'Delphi XE4'; BDS: '11.0'; Define: 'VER250'; Compiler: '25.0'),
(Name: 'Delphi XE5'; BDS: '12.0'; Define: 'VER260'; Compiler: '26.0'),
(Name: 'Delphi XE6'; BDS: '14.0'; Define: 'VER270'; Compiler: '27.0'),
(Name: 'Delphi XE7'; BDS: '15.0'; Define: 'VER280'; Compiler: '28.0'),
(Name: 'Delphi XE8'; BDS: '16.0'; Define: 'VER290'; Compiler: '29.0'),
(Name: 'Delphi 10 Seattle'; BDS: '17.0'; Define: 'VER300'; Compiler: '30.0'),
(Name: 'Delphi 10.1 Berlin'; BDS: '18.0'; Define: 'VER310'; Compiler: '31.0'),
(Name: 'Delphi 10.2 Tokyo'; BDS: '19.0'; Define: 'VER320'; Compiler: '32.0'),
(Name: 'Delphi 10.3 Rio'; BDS: '20.0'; Define: 'VER330'; Compiler: '33.0'),
(Name: 'Delphi 10.4 Sydney'; BDS: '21.0'; Define: 'VER340'; Compiler: '34.0'),
(Name: 'Delphi 11 Alexandria'; BDS: '22.0'; Define: 'VER350'; Compiler: '35.0'),
(Name: 'Delphi 12 Athens'; BDS: '23.0'; Define: 'VER360'; Compiler: '36.0'),
(Name: 'Delphi 13 Florence'; BDS: '37.0'; Define: 'VER370'; Compiler: '37.0'));
var
StdOut: THandleStream;
procedure Log(const AText: string);
begin
// Standard error is free for diagnostics; the client may record it
System.Write(ErrOutput, AText + sLineBreak);
end;
procedure Send(AMessage: TJSONObject);
var
LBytes: TBytes;
begin
try
LBytes := TEncoding.UTF8.GetBytes(AMessage.ToJSON + #10);
StdOut.WriteBuffer(LBytes, Length(LBytes));
finally
AMessage.Free;
end;
end;
procedure Send_Result(AId: TJSONValue; AResult: TJSONObject);
begin
Send(TJSONObject.Create
.AddPair('jsonrpc', '2.0')
.AddPair('id', AId.Clone as TJSONValue)
.AddPair('result', AResult));
end;
procedure Send_Error(AId: TJSONValue; ACode: Integer; const AMessage: string);
begin
Send(TJSONObject.Create
.AddPair('jsonrpc', '2.0')
.AddPair('id', AId.Clone as TJSONValue)
.AddPair('error', TJSONObject.Create
.AddPair('code', TJSONNumber.Create(ACode))
.AddPair('message', AMessage)));
end;
function Text_Result(const AText: string; AIs_Error: Boolean = False): TJSONObject;
begin
Result := TJSONObject.Create
.AddPair('content', TJSONArray.Create.Add(TJSONObject.Create
.AddPair('type', 'text')
.AddPair('text', AText)) as TJSONArray)
.AddPair('isError', TJSONBool.Create(AIs_Error));
end;
{ Tools }
function Tool_List: TJSONObject;
var
LTools: TJSONArray;
begin
LTools := TJSONArray.Create;
LTools.Add(TJSONObject.Create
.AddPair('name', 'delphi_version')
.AddPair('description',
'Looks up a Delphi release from its name, product version, BDS registry version, ' +
'VERxxx conditional define or CompilerVersion, and returns all four.')
.AddPair('inputSchema', TJSONObject.ParseJSONValue(
'{"type":"object","properties":{"query":{"type":"string",' +
'"description":"For example: Athens, 12, 23.0, VER360 or 36"}},"required":["query"]}')));
LTools.Add(TJSONObject.Create
.AddPair('name', 'installed_delphi_versions')
.AddPair('description',
'Lists the Delphi / RAD Studio versions installed on this computer, with their folders.')
.AddPair('inputSchema', TJSONObject.ParseJSONValue('{"type":"object","properties":{}}')));
Result := TJSONObject.Create.AddPair('tools', LTools);
end;
function Describe(const ARelease: TDelphi_Release): string;
begin
Result := Format('%s: BDS %s, %s, CompilerVersion %s',
[ARelease.Name, ARelease.BDS, ARelease.Define, ARelease.Compiler]);
end;
function Delphi_Version(const AQuery: string): TJSONObject;
var
LQuery, LMatches: string;
LRelease: TDelphi_Release;
begin
LQuery := AQuery.Trim.ToUpper;
if LQuery = '' then
Exit(Text_Result('Give a release name or version number.', True));
LMatches := '';
for LRelease in Releases do
if (LRelease.Name.ToUpper.Contains(LQuery)) or
(LRelease.BDS = LQuery) or
(LRelease.Define = LQuery) or
(LRelease.Compiler = LQuery) or
(LRelease.Compiler = LQuery + '.0') then
LMatches := LMatches + Describe(LRelease) + sLineBreak;
if LMatches = '' then
Result := Text_Result('No Delphi release matches "' + AQuery + '".', True)
else
Result := Text_Result(LMatches.TrimRight);
end;
function Installed_Delphi_Versions: TJSONObject;
var
LRegistry: TRegistry;
LKeys: TStringList;
LKey, LRoot, LText: string;
LRelease: TDelphi_Release;
begin
LText := '';
LKeys := TStringList.Create;
LRegistry := TRegistry.Create(KEY_READ or KEY_WOW64_32KEY);
try
LRegistry.RootKey := HKEY_LOCAL_MACHINE;
if LRegistry.OpenKeyReadOnly('SOFTWARE\Embarcadero\BDS') then
begin
LRegistry.GetKeyNames(LKeys);
LRegistry.CloseKey;
end;
for LKey in LKeys do
if LRegistry.OpenKeyReadOnly('SOFTWARE\Embarcadero\BDS\' + LKey) then
begin
LRoot := LRegistry.ReadString('RootDir');
LRegistry.CloseKey;
if LRoot = '' then
Continue;
LText := LText + Format('BDS %s at %s', [LKey, LRoot]);
for LRelease in Releases do
if LRelease.BDS = LKey then
LText := LText + ' - ' + Describe(LRelease);
LText := LText + sLineBreak;
end;
finally
LRegistry.Free;
LKeys.Free;
end;
if LText = '' then
Result := Text_Result('No Delphi installations found.')
else
Result := Text_Result(LText.TrimRight);
end;
function Call_Tool(AParams: TJSONObject): TJSONObject;
var
LName: string;
LArguments: TJSONObject;
begin
LName := AParams.GetValue<string>('name', '');
if not AParams.TryGetValue<TJSONObject>('arguments', LArguments) then
LArguments := nil;
if LName = 'delphi_version' then
begin
if LArguments = nil then
Exit(Text_Result('The query argument is required.', True));
Result := Delphi_Version(LArguments.GetValue<string>('query', ''));
end
else if LName = 'installed_delphi_versions' then
Result := Installed_Delphi_Versions
else
Result := nil;
end;
{ Protocol }
function Initialize_Result(AParams: TJSONObject): TJSONObject;
var
LRequested, LVersion: string;
begin
// Agree to the client's protocol version if it is one we know,
// otherwise offer our latest and let the client decide
LRequested := '';
if AParams <> nil then
LRequested := AParams.GetValue<string>('protocolVersion', '');
LVersion := Latest_Protocol;
for var LSupported in Supported_Protocols do
if LSupported = LRequested then
LVersion := LRequested;
Result := TJSONObject.Create
.AddPair('protocolVersion', LVersion)
.AddPair('capabilities', TJSONObject.Create
.AddPair('tools', TJSONObject.Create))
.AddPair('serverInfo', TJSONObject.Create
.AddPair('name', Server_Name)
.AddPair('version', Server_Version));
end;
procedure Handle(const ALine: string);
var
LMessage, LParams, LResult: TJSONObject;
LId: TJSONValue;
LMethod: string;
begin
LMessage := TJSONObject.ParseJSONValue(ALine) as TJSONObject;
if LMessage = nil then
begin
Log('Not JSON: ' + ALine);
Exit;
end;
try
LMethod := LMessage.GetValue<string>('method', '');
LId := LMessage.GetValue('id');
if not LMessage.TryGetValue<TJSONObject>('params', LParams) then
LParams := nil;
Log('<- ' + LMethod);
// A message without an id is a notification and gets no reply
if LId = nil then
Exit;
if LMethod = 'initialize' then
Send_Result(LId, Initialize_Result(LParams))
else if LMethod = 'ping' then
Send_Result(LId, TJSONObject.Create)
else if LMethod = 'tools/list' then
Send_Result(LId, Tool_List)
else if LMethod = 'tools/call' then
begin
if LParams = nil then
LResult := nil
else
LResult := Call_Tool(LParams);
if LResult = nil then
Send_Error(LId, Invalid_Params, 'Unknown tool')
else
Send_Result(LId, LResult);
end
else
Send_Error(LId, Method_Not_Found, 'Method not found: ' + LMethod);
finally
LMessage.Free;
end;
end;
var
StdIn: THandleStream;
Pending: TBytes;
// Returns the next line from standard input, waiting until one arrives.
// TStreamReader is not used: it waits to fill its buffer before returning
// anything, so it would sit on a request until more input or the end of the
// input arrived, while the client sits waiting for the reply.
function Read_Line(out ALine: string): Boolean;
var
LBuffer: array [0..4095] of Byte;
LCount, LOld, I: Integer;
begin
repeat
for I := 0 to High(Pending) do
if Pending[I] = 10 then
begin
ALine := TEncoding.UTF8.GetString(Pending, 0, I).TrimRight;
Pending := Copy(Pending, I + 1, Length(Pending));
Exit(True);
end;
// Returns as soon as any input is available; 0 when the client has closed it
LCount := StdIn.Read(LBuffer, SizeOf(LBuffer));
if LCount <= 0 then
Exit(False);
LOld := Length(Pending);
SetLength(Pending, LOld + LCount);
Move(LBuffer, Pending[LOld], LCount);
until False;
end;
var
Line: string;
begin
StdIn := THandleStream.Create(GetStdHandle(STD_INPUT_HANDLE));
StdOut := THandleStream.Create(GetStdHandle(STD_OUTPUT_HANDLE));
try
Log(Server_Name + ' started');
while Read_Line(Line) do
if Line.Trim <> '' then
try
Handle(Line);
except
on E: Exception do
Log('Error: ' + E.Message);
end;
Log(Server_Name + ' stopped');
finally
StdIn.Free;
StdOut.Free;
end;
end.
Every byte written to standard output must be part of a JSON-RPC message. A stray
WriteLn - yours, or one buried in a library you use - corrupts the stream, and
the client reports an error or drops the connection. Write diagnostics to standard error,
as Log does; Claude Desktop keeps standard error in its log files.
The obvious way to read lines from a stream in Delphi is TStreamReader, and it
appears to work: feed it a file of requests and every one is answered. Connected to a real
client it hangs. TStreamReader waits to fill its buffer before it returns a line,
and the client sends one request and then waits for the answer, so neither side moves. Claude
Code reports connection timed out after 30 seconds. A file hides the problem because its
end arrives straight away and releases the buffer.
Read_Line avoids this. THandleStream.Read on a pipe returns as soon
as any bytes are available, so the function collects bytes until a line feed arrives and
returns that line immediately. A request split across two writes, or two requests arriving
together, are both handled. When testing a server, keep its input open the way a client does
- testing only with a file of requests misses this.
ReadLn and WriteLn on a console program use the console code
page, not UTF-8, so a tool result containing é or a Chinese file name
would be mangled. The program reads and writes the raw handles instead, through
THandleStream, decoding each line with TEncoding.UTF8 only once the
whole line has arrived, so a character split between two reads is never cut in half.
ToJSON also escapes any character that needs it, so a reply never contains a line
break of its own.
A request the server cannot handle at all - an unknown method, an unknown tool - gets a
JSON-RPC error. A tool that ran but failed returns a normal result with
"isError": true and an explanation in the text. The difference matters: the
model sees a tool's error text and can correct itself, for example by trying a different
query, whereas a protocol error goes to the client.
The model chooses tools by their descriptions, and fills in arguments from the schema's
descriptions. Say what the tool does, what it returns, and give examples of valid input, as
the query description does. A vague description gets a tool used at the wrong
time or not at all.
MCP versions are dates. The server agrees to the client's version if it knows it, and otherwise offers the newest it supports; the client then decides whether it can work with that. The messages used here have not changed between these versions.
Because it is just standard input and output, the server can be tested without any AI involved. Put some requests in a file, one per line:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"installed_delphi_versions","arguments":{}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"no_such_tool"}}
and feed it to the program from a Command Prompt (PowerShell does not accept <; use Get-Content requests.txt | .\Delphi_Mcp.exe there):
Delphi_Mcp.exe < requests.txt
Each request gets one line back, and the log lines appear separately on standard error. On
a machine with five versions installed, the third reply listed all five with their folders,
and the fourth was an Unknown tool error, as it should be.
This checks the messages but not the timing, because the end of the file arrives
immediately. The real test is a client that keeps the connection open: claude mcp
list starts the server, connects, and shows Connected or the error.
For an interactive test, the MCP Inspector is a small web page that connects to a server, lists its tools and lets you call them with a form. It needs Node.js:
npx @modelcontextprotocol/inspector C:\Tools\Delphi_Mcp.exe
claude mcp add --transport stdio delphi-versions -- "C:\Tools\Delphi_Mcp.exe"
Check it with claude mcp list. Everything after -- is the command that starts the server, including any
arguments it needs. By default the server is available in the current project only, for you
only. Add --scope user to have it in every project, or --scope
project to write it to a .mcp.json file that is shared with the project.
Claude Code asks before using a tool for the first time.
Add the server to %APPDATA%\Claude\claude_desktop_config.json, then quit and
restart Claude Desktop completely:
{
"mcpServers": {
"delphi-versions": {
"command": "C:\\Tools\\Delphi_Mcp.exe",
"args": []
}
}
}
Backslashes in JSON are doubled. If the server does not appear, the logs in
%APPDATA%\Claude\logs show what was written to standard error, including the
"started" line from Log.
The server runs as you, with your files, your network and your database logins. The model decides when to call each tool and what arguments to pass, and its decisions can be steered by text it has read elsewhere. So:
run_sql that takes any statement
is a database console, not a tool. find_customer taking a name is safer and
easier for the model to use correctly.The article on what not to let an AI coding assistant do applies to tools you write just as much as to the ones built in.
isError for failed
toolsclaude mcp add --transport stdio name -- execlaude_desktop_config.json, then restart