Pacific Blue Software Logo

How to Write an MCP Server in Delphi

How to Write an MCP Server in Delphi

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.


How the Conversation Goes

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}}
  1. initialize - the client says which protocol version it speaks; the server answers with the version it will use, what it offers (here, only tools) and its name.
  2. notifications/initialized - the client is ready. No reply.
  3. tools/list - the server describes each tool: a name, a description, and a JSON Schema for its arguments.
  4. tools/call - whenever the model decides to use a tool. The result is a list of content blocks, here a single piece of text.

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.


The Server

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.

The Details That Matter

Standard output is only for the protocol

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.

Read input as it arrives - not with TStreamReader

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.

UTF-8 in and out

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.

Two kinds of error

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.

Descriptions are instructions

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.

Protocol versions

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.


Testing It by Hand

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

Connecting It to Claude

Claude Code

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.

Claude Desktop

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.


Be Careful What You Expose

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:

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.


Summary


Useful References


PBS Delphi - Giving Claude Tools Written in Pascal


Back to Articles for Developers
Back to Articles on AI
Back to Articles on Delphi
How to Call the Claude API from Delphi