Cookbook
Complete, runnable patterns. Every snippet here is lifted from — or
directly runnable against — the demo server,
mcpdemo.pas: the smallest complete
pascal-mcp-sdk program, exercising every registration style. Clone the
repo and lwpt build (or fpc @lwpt.cfg -FEbuild source/apps/mcpdemo.pas — no lwpt needed) to get build/mcpdemo.
One server, both transports
The same registrations serve stdio by default and Streamable HTTP behind a flag — the transport is chosen at the end, not woven through the code:
if UseHTTP then
begin
Transport := TMCPHTTPServer.Create(Server);
try
Transport.Port := HTTPPort;
MCPLogToStderr('serving on http://127.0.0.1:' +
IntToStr(HTTPPort) + Transport.EndpointPath);
Transport.Run; // blocks until Transport.Stop
finally
Transport.Free;
end;
end
else
begin
MCPLogToStderr('serving on stdio');
RunMCPStdioServer(Server); // serves until the client closes stdin
end;Try both against the demo: ./build/mcpdemo (stdio) and
./build/mcpdemo --http 3000.
A tool with progress and log notifications
echo reports progress and logs unconditionally — both calls no-op
unless the client opted in, so the handler needs no conditionals:
function EchoHandler(AArguments: TJSONObject;
const ACtx: TMCPRequestContext): TMCPToolResult;
var
MessageData: TJSONData;
begin
MessageData := AArguments.Find('message');
if MessageData = nil then
Exit(MCPErrorResult('Missing required argument "message"'));
if MessageData.JSONType <> jtString then
Exit(MCPErrorResult('Argument "message" must be a string'));
MCPReportProgress(ACtx, 0.5, 1.0, 'echoing');
MCPLogMessage(ACtx, 'info', 'echo invoked');
Result := MCPTextResult(MessageData.AsString);
MCPReportProgress(ACtx, 1.0, 1.0);
end;
Server.RegisterTool('echo', 'Echo a message back to the caller',
ObjectSchema.AddString('message', 'Text to echo back'),
EchoHandler);(The manual argument checks are belt-and-braces here — the fluent
schema already makes the server reject a missing or mistyped
message before the handler runs.)
Typed input, typed output, annotations
add declares both schemas as classes and chains behavior hints:
type
TAddArgs = class(TMCPArgs)
private
FA, FB: Double;
published
property a: Double read FA write FA;
property b: Double read FB write FB;
end;
TSumResult = class(TMCPArgs)
private
FSum: Double;
published
property sum: Double read FSum write FSum;
end;
function AddHandler(AArgs: TMCPArgs;
const ACtx: TMCPRequestContext): TMCPToolResult;
var
Res: TSumResult;
begin
Res := TSumResult.Create;
Res.sum := (AArgs as TAddArgs).a + (AArgs as TAddArgs).b;
Result := MCPStructuredResult('The sum is ' + FloatToStr(Res.sum), Res);
end;
Server.RegisterTool('add', 'Add two numbers and return the sum',
TAddArgs, TSumResult, AddHandler)
.Title('Adder').ReadOnlyHint.IdempotentHint;A call answers both human-readable content and
structuredContent: {"sum": 42} matching the output schema.
A tool that asks the user something (MRTR)
greet_user runs in two rounds: the first answers input_required
with an elicitation form; the client shows it, gathers the answer, and
retries the same call, which re-enters the same handler:
function GreetUserHandler(AArguments: TJSONObject;
const ACtx: TMCPRequestContext): TMCPToolResult;
var
Content: TJSONObject;
begin
Content := MCPElicitationContent(ACtx, 'who');
if Content = nil then // round 1: ask
Exit(MCPInputRequired(TJSONObject.Create(['who',
MCPElicitFormRequest('Who should be greeted?',
ObjectSchema.AddString('name', 'Name of the person to greet'))]),
'greet-round-1'));
// round 2: the answer arrived on ACtx
Result := MCPTextResult('Hello, ' + Content.Get('name', 'stranger') + '!');
end;
Server.RegisterTool('greet_user',
'Greet a person; asks who to greet via elicitation (MRTR)',
'{"type":"object"}', GreetUserHandler);The raw '{"type":"object"}' schema means "no declared arguments" —
this tool gets its input by asking, not from the caller.
A prompt with arguments
function GreetPromptHandler(AArguments: TJSONObject;
const ACtx: TMCPRequestContext): TJSONArray;
begin
Result := MCPMessages([MCPUserMessage(
'Compose a short, friendly greeting for ' +
AArguments.Get('name', 'the user') + '.')]);
end;
Server.RegisterPrompt('greet', 'Compose a friendly greeting',
PromptArguments.Add('name', 'Who to greet'), GreetPromptHandler);A static resource and a template
Server.RegisterTextResource('mcp://pascal-mcp-sdk/greeting', 'greeting',
'text/plain', 'Hello from pascal-mcp-sdk, a FreePascal MCP server.',
'A static greeting resource');
function ShoutReader(const AUri: string; AVars: TJSONObject;
const ACtx: TMCPRequestContext): TJSONArray;
begin
Result := MCPTextContents(AUri, 'text/plain',
UpperCase(AVars.Get('text', '')));
end;
Server.RegisterResourceTemplate('mcp://pascal-mcp-sdk/shout/{text}',
'shout', 'text/plain', ShoutReader, 'Uppercase echo of {text}');Reading mcp://pascal-mcp-sdk/shout/hello answers HELLO — the
{text} variable arrives in AVars.
Driving it all by hand
Every request needs _meta; the heredoc closes stdin, which is the
graceful shutdown:
./build/mcpdemo <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":19,"b":23},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"mcp://pascal-mcp-sdk/shout/hello","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
EOFOver HTTP, the same bodies POST to the endpoint:
./build/mcpdemo --http 3000 &
curl -s http://127.0.0.1:3000/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'Where to go deeper
The demo server is ~200 lines including comments — read it whole. The E2E battery mcpsmoke.pas drives every one of these registrations the way a real client does, error paths included, and doubles as a protocol-level reference in Pascal.