Prompt messages
A prompt handler does not return text — it returns a conversation. The handler
builds a TTMSMCPPromptMessages collection, and every message in it is inserted
into the client's chat as if it had been typed there, in order. That is what
makes a prompt more than a template: you can seed a short exchange that
establishes context, pin down the shape of the answer, and only then ask the real
question. This guide covers how arguments reach the handler, the two roles a
message may take, the content type, what the framework does with the collection
you return, and how to offer the user suggestions while they fill an argument in.
Reading the arguments
Why it matters: the handler sees values, not names, so the mapping from the
declared argument list to Args is something you have to get right by hand.
Every declared argument produces one entry in Args, in declaration order, and
every value arrives as a string whatever JSON type the client sent. A
required argument is guaranteed to be there — the framework rejects the call
before the handler runs if it is missing. An optional argument the client
omitted arrives as an empty TValue:
function TForm1.BuildMigrationPrompt(
const Args: array of TValue): TTMSMCPPromptMessages;
var
UnitName, TargetFramework: string;
begin
{ Args[0] is the first declared argument, Args[1] the second, and so on.
Every value arrives as a string, whatever the client sent. }
UnitName := Args[0].AsString;
{ A required argument is guaranteed to be present - the framework
rejects the call otherwise. An optional one may be empty. }
TargetFramework := 'FMX';
if (Length(Args) > 1) and not Args[1].IsEmpty then
TargetFramework := Args[1].AsString;
Result := TTMSMCPPromptMessages.Create(nil);
Result.AddUserMessage(Format(
'I am migrating the unit %s to %s. List the steps before writing code.',
[UnitName, TargetFramework]));
end;
Length(Args) equals the number of declared arguments, so the guard above is
belt and braces; the IsEmpty test is the one that matters.
Message roles
Why it matters: the role decides who appears to have said the message, and the framework accepts only two of them.
TTMSMCPPromptMessage.Role accepts 'user' and 'assistant', and nothing
else — assigning any other value raises a JSON-RPC invalid-params error. There
is no system role in this API; instructions that would otherwise be a system
message belong in the first user message.
| Helper | Role set | Use for |
|---|---|---|
AddUserMessage(Text) |
user |
What the user is asking, and any context they are supplying. |
AddAssistantMessage(Text) |
assistant |
A seeded reply that shapes how the model continues. |
Add |
none — set Role yourself |
Full control over Role, ContentType, and Text. |
function TForm1.BuildStyleGuidePrompt(
const Args: array of TValue): TTMSMCPPromptMessages;
var
Msg: TTMSMCPPromptMessage;
begin
{ The framework serializes the returned collection and owns it from
then on. Do not free it here. }
Result := TTMSMCPPromptMessages.Create(nil);
{ The convenience methods set Role and Text for you. }
Result.AddUserMessage(
'These are the coding conventions I want you to follow.');
Result.AddAssistantMessage(
'Understood. I will apply them to every suggestion.');
{ Add gives full control. Role accepts only "user" or "assistant";
ContentType defaults to "text". }
Msg := Result.Add;
Msg.Role := 'user';
Msg.ContentType := 'text';
Msg.Text := Format('Rewrite the following unit to match them:'#13#10#13#10'%s',
[LoadUnitSource(Args[0].AsString)]);
end;
ContentType defaults to 'text' and is written into each message's content
object. Like Role, it rejects an empty value.
Returning the collection
Why it matters: the collection crosses an ownership boundary, and freeing it on the wrong side is the one memory bug this API invites.
Create the collection inside the handler — TTMSMCPPromptMessages.Create(nil) —
fill it, and return it. From that point it belongs to the framework, which
serializes it into the prompts/get result. Do not free it in the handler.
Two further behaviours are worth knowing:
- A handler that returns
nilis not an error.TTMSMCPPrompt.Executesubstitutes an empty collection, so the client receives a prompt with no messages rather than a failure. - The prompt's
Descriptionis copied into theprompts/getresult alongside the messages, so a client can show what the prompt does next to what it inserted.
An exception raised inside the handler is converted into a JSON-RPC error, so failing loudly on bad input is safe and produces a message the user can act on. The next section says which error code the client receives.
How the server answers prompts/get
Why it matters: everything above — positional arguments, string values, the rejection of a missing required argument, the description travelling with the messages — is the behaviour of one method, and knowing where it happens tells you which failures are yours to handle and which are not.
When a client calls prompts/get, the server finds the prompt by name and calls
TTMSMCPPrompt.GetPrompt, passing the client's arguments as a single
TJSONObject. That method does four things in order:
- Checks that a handler is assigned.
- Rejects the call if any argument declared
Requiredis absent from the JSON object. - Converts each declared argument, in declaration order, into one
TValuein the array the handler receives — always as a string, with an omitted optional argument becoming an emptyTValue. - Calls
Execute, then builds the result object: the prompt'sDescription, when it has one, plus the serialized messages.
Each of those failures maps to a standard JSON-RPC error code, which is what the client reports to the user:
| Situation | Error code |
|---|---|
| A required argument is missing, or a value cannot be converted | ecInvalidParams (-32602) |
| No handler is assigned, or the messages cannot be serialized | ecInternalError (-32603) |
| The handler itself raised | ecOperationFailed (-32004) |
A RaiseJsonRpcError you call inside the handler passes through unchanged, so
raising ecInvalidParams yourself for an argument the schema cannot police — an
unparseable date, an unknown identifier — reaches the client as the code you
chose rather than as a generic execution failure. The full list of codes is on
the TTMSMCPErrorCode page.
Seeding a multi-turn opening
Why it matters: one long user message and a short exchange do not behave the same way — the exchange lets you commit the assistant to an approach before the question arrives.
Messages are inserted in the order you add them, so alternating roles builds a conversation that already has a direction. A common shape is: a user message carrying the context, an assistant message stating the approach, and a final user message asking for the work. The combined example below uses exactly that shape.
Keep the seeded assistant turn short and factual. It is a commitment the model will follow, not a place for content.
Suggesting argument values
Why it matters: an argument that takes an identifier or one of a fixed set of values is hard to fill in blind, and MCP has a completion call for it.
Set EnableCompletions to True on the server and handle OnCompletion. The
event fires for completion/complete and carries two JSON objects: ARef
identifies what is being completed (type is ref/prompt and name is the
prompt name, for a prompt argument) and AArgument carries the argument's
name plus the partial value the user has typed:
procedure TForm1.FormCreate(Sender: TObject);
begin
Server.EnableCompletions := True;
Server.OnCompletion := HandleCompletion;
end;
procedure TForm1.HandleCompletion(Sender: TObject;
const ARef, AArgument: TJSONObject; var AValues: TArray<string>;
var ATotal: Integer; var AHasMore: Boolean);
var
RefType, PromptName, ArgumentName, Typed: string;
begin
{ ARef identifies what is being completed - "ref/prompt" plus the prompt
name for a prompt argument. AArgument carries the argument name and
whatever the user has typed so far. }
ARef.TryGetValue<string>('type', RefType);
ARef.TryGetValue<string>('name', PromptName);
AArgument.TryGetValue<string>('name', ArgumentName);
AArgument.TryGetValue<string>('value', Typed);
if (RefType <> 'ref/prompt') or (PromptName <> 'review_unit') then
Exit;
if ArgumentName = 'focus' then
AValues := ['memory management', 'thread safety', 'error handling']
else if ArgumentName = 'unit_name' then
AValues := MatchingUnitNames(Typed);
ATotal := Length(AValues);
AHasMore := False;
end;
Set AValues to the suggestions, ATotal to how many exist in total, and
AHasMore to True when you truncated the list. Leaving AValues untouched is
a valid answer — the client simply gets no suggestions for that argument.
Combining arguments, roles, and a multi-turn opening
A production prompt handler usually does all of this at once. This one defaults
an optional argument the client may have omitted, opens with a user/assistant
exchange that fixes the approach, and adds a final message through Add so the
content type is set explicitly:
procedure TForm1.RegisterOnboardingPrompt;
var
Prompt: TTMSMCPPrompt;
begin
Prompt := Server.Prompts.Add;
Prompt.Name := 'onboard_developer';
Prompt.Title := 'Onboard a developer';
Prompt.Description :=
'Opens a guided conversation that introduces one module to a new developer';
Prompt.Arguments.Add('module', 'Module to introduce', True);
Prompt.Arguments.Add('experience',
'Experience level of the reader, for example junior or senior');
Prompt.PromptMethod :=
function(const Args: array of TValue): TTMSMCPPromptMessages
var
Experience: string;
Msg: TTMSMCPPromptMessage;
begin
{ An optional argument the client omitted arrives empty. }
Experience := 'a developer new to the codebase';
if (Length(Args) > 1) and not Args[1].IsEmpty then
Experience := Args[1].AsString;
Result := TTMSMCPPromptMessages.Create(nil);
{ A short user/assistant exchange sets the shape of the answer
before the real question is asked. }
Result.AddUserMessage(Format(
'Explain the %s module to %s.', [Args[0].AsString, Experience]));
Result.AddAssistantMessage(
'I will start with the public entry points, then the data flow.');
Msg := Result.Add;
Msg.Role := 'user';
Msg.ContentType := 'text';
Msg.Text := Format(
'Here is the unit list for that module:'#13#10'%s',
[ModuleUnitList(Args[0].AsString)]);
end;
end;
The handler is assigned to PromptMethod directly here rather than through the
builder, which is the shorter route when the prompt is created with
Prompts.Add and configured in place.
Common mistakes
- Freeing the returned collection. The framework owns it once the handler
returns. A
try … finally Result.Freearound the build is a dangling reference in the serializer. - Using a system role.
Roleaccepts only'user'and'assistant';'system'raises. Put standing instructions in the first user message. - Assuming the argument is typed. Everything arrives as a string. Parse numbers and dates in the handler, and say what format you expect in the argument description.
- Reading an optional argument without checking.
Args[n].AsStringon an emptyTValuedoes not give you the default you meant. TestIsEmptyfirst. - Returning one giant user message. If the prompt's job is to establish an approach, seed the exchange — a single wall of text gives the model no shape to follow.
- Handling completion without enabling it.
OnCompletiononly fires whenEnableCompletionsisTrue; the capability is advertised during initialization, so set it before the server starts.
See also
- Defining prompts — names, arguments, and registration
- MCP Server — sessions, capabilities, and notifications
- Tools — the model-chosen counterpart to a user-chosen prompt
TTMSMCPPromptMessages,TTMSMCPPromptMessage,TTMSMCPPromptMethod