Defining prompts
A prompt is a reusable, named conversation opener that a server offers and a user picks — a slash command in a chat client, an entry in a prompt palette, a "summarize this ticket" button. Unlike a tool, a prompt is not chosen by the model: the user chooses it, fills in its arguments, and the resulting messages are inserted into the conversation. That makes the declaration a small contract with two audiences. The name and title are what a person sees in a list, and the arguments are what they are asked to fill in, so both have to read well outside your code. This guide covers the two ways to declare a prompt, how arguments are declared and ordered, the presentation metadata a client can show, and how to grow the list at runtime and tell connected clients it changed.
Registering a prompt
TTMSMCPPrompts.RegisterPrompt is the compact form. It creates the item, sets
the name and description, and attaches the handler in one call:
procedure TForm1.RegisterSummarizePrompt;
var
Arg: TTMSMCPPromptArgument;
begin
{ Server is a TTMSMCPServer dropped on the form. }
Server.Prompts.RegisterPrompt('summarize_ticket',
'Asks the model to summarize a support ticket in three sentences',
function(const Args: array of TValue): TTMSMCPPromptMessages
begin
Result := TTMSMCPPromptMessages.Create(nil);
Result.AddUserMessage(Format(
'Summarize support ticket %s in three sentences.', [Args[0].AsString]));
end);
{ RegisterPrompt declares no arguments, so declare them separately -
the client needs them to build the prompts/get call. }
Arg := Server.Prompts.FindByName('summarize_ticket').Arguments.Add;
Arg.Name := 'ticket_id';
Arg.Description := 'Identifier of the ticket to summarize';
Arg.Required := True;
end;
Note what RegisterPrompt does not do: it declares no arguments. The handler
receives Args positionally, and the client only knows what to send from the
arguments you declare — so declare them through Arguments.Add, as above, or
use the builder, which keeps them next to the handler that reads them.
RegisterPrompt rejects an empty name, a nil handler, and a name that is
already taken. All three raise a JSON-RPC error rather than failing silently, so
a duplicate registration during start-up surfaces immediately.
Using the fluent builder
TTMSMCPPrompt.CreateBuilder returns a builder whose every method returns the
builder again, so a complete declaration reads as one expression:
procedure TForm1.RegisterReviewUnitPrompt;
var
Prompt: TTMSMCPPrompt;
begin
Prompt := TTMSMCPPrompt.CreateBuilder
.Name('review_unit')
.Title('Review a unit')
.Description('Asks the model to review one source unit for a named concern')
.Handler(
function(const Args: array of TValue): TTMSMCPPromptMessages
begin
Result := TTMSMCPPromptMessages.Create(nil);
Result.AddUserMessage(Format(
'Review the unit %s. Focus on %s and list findings as a numbered list.',
[Args[0].AsString, Args[1].AsString]));
end)
.AddArgument
.Name('unit_name')
.Description('Unit to review, for example TMS.MCP.Prompts.pas')
.Required(True)
.&End
.AddArgument
.Name('focus')
.Description('What to concentrate on, for example memory management')
.Required(False)
.&End
.Build;
{ AddPrompt copies the declaration into the server collection instead of
reparenting the instance, so do not free the prompt you built. }
Server.Prompts.AddPrompt(Prompt);
end;
Two syntax details are worth stating plainly. AddArgument switches from the
prompt builder to the argument builder, and .&End switches back — the
ampersand is required because End is a reserved word. And Build validates
before it returns: a prompt with no name or no handler raises rather than
producing a half-declared item.
Ownership differs from the tools collection here, and the difference matters.
TTMSMCPPrompts.AddPrompt copies the declaration — name, title,
description, icons, handler, and every argument — into a new item in the
collection. It does not reparent the instance you pass, so the built prompt is
not owned by the server collection and must not be freed after adding.
Declaring arguments
An argument is a named slot the user fills in before the prompt runs.
TTMSMCPPromptArgument carries just three things: a Name, a Description,
and a Required flag. There is no type — every value reaches the handler as a
string.
procedure TForm1.RegisterTranslatePrompt;
var
Prompt: TTMSMCPPrompt;
begin
Prompt := Server.Prompts.Add;
Prompt.Name := 'translate_snippet';
Prompt.Description := 'Asks the model to translate a text fragment';
{ The three-parameter Add overload sets name, description, and the
required flag in one call. Declaration order is argument order. }
Prompt.Arguments.Add('text', 'Text to translate', True);
Prompt.Arguments.Add('target_language', 'Language to translate into', True);
Prompt.Arguments.Add('tone', 'Desired tone, for example formal or casual');
Prompt.PromptMethod :=
function(const Args: array of TValue): TTMSMCPPromptMessages
var
Tone: string;
begin
Tone := 'neutral';
if (Length(Args) > 2) and not Args[2].IsEmpty then
Tone := Args[2].AsString;
Result := TTMSMCPPromptMessages.Create(nil);
Result.AddUserMessage(Format(
'Translate the following text into %s using a %s tone.'#13#10#13#10'%s',
[Args[1].AsString, Tone, Args[0].AsString]));
end;
end;
Three rules follow from how arguments are dispatched:
| Rule | Consequence |
|---|---|
| Arguments are passed positionally, in declaration order | Args[0] is the first declared argument. Inserting a new argument in the middle shifts every later index. |
| A missing required argument is rejected before the handler runs | You never have to check for it. The client gets Required argument "x" is missing. |
A missing optional argument arrives as an empty TValue |
Test with Args[n].IsEmpty and supply a default. |
Because the position is the contract, append new arguments at the end of an existing prompt rather than inserting them, and keep the descriptions specific — they are what the user reads in the client's argument form.
Names, titles, and icons
Name is the protocol identifier a client uses in prompts/get. Keep it in the
stable lowercase underscore form other MCP servers use, and treat it as frozen
once a server has shipped: saved conversations and client configurations refer
to a prompt by name.
Title is the human-readable label. It is free to change, so put the readable
wording there. AddIcon attaches one or more icon variants — a source URL, an
optional MIME type, and an optional size — which a client may show next to the
title:
procedure TForm1.RegisterReleaseNotesPrompt;
var
Prompt: TTMSMCPPrompt;
begin
Prompt := TTMSMCPPrompt.CreateBuilder
.Name('draft_release_notes')
.Title('Draft release notes')
.Description('Drafts release notes from a list of committed changes')
.AddIcon('https://cdn.example.com/icons/notes-24.png', 'image/png', '24x24')
.AddIcon('https://cdn.example.com/icons/notes-48.png', 'image/png', '48x48')
.Handler(
function(const Args: array of TValue): TTMSMCPPromptMessages
begin
Result := TTMSMCPPromptMessages.Create(nil);
Result.AddUserMessage(Format(
'Turn these changes into release notes for version %s:'#13#10'%s',
[Args[0].AsString, LoadChangeLog(Args[0].AsString)]));
end)
.AddArgument
.Name('version')
.Description('Version the notes are written for, for example 2.0.0.0')
.Required(True)
.&End
.Build;
Server.Prompts.AddPrompt(Prompt);
end;
Each AddIcon call appends to the IconsJSON string rather than replacing it,
so several calls declare several sizes of the same icon. A client picks the
variant that fits its display.
Listing prompts dynamically
Not every prompt is known at start-up. OnListPrompts on the server fires on
every prompts/list call, with the live collection, so the list can reflect
what the application currently has loaded:
procedure TForm1.FormCreate(Sender: TObject);
begin
Server.EnablePromptNotifications := True;
Server.OnListPrompts := HandleListPrompts;
end;
procedure TForm1.HandleListPrompts(Sender: TObject;
const PromptsList: TTMSMCPPrompts);
var
Prompt: TTMSMCPPrompt;
ProjectName: string;
begin
{ Runs on every prompts/list call, before the list is serialized. }
ProjectName := CurrentProjectName;
if ProjectName = '' then
Exit;
{ FindByName raises on an empty name, so guard before searching, and
skip the work when the prompt is already registered. }
if PromptsList.FindByName('summarize_current_project') <> nil then
Exit;
Prompt := PromptsList.Add;
Prompt.Name := 'summarize_current_project';
Prompt.Title := Format('Summarize %s', [ProjectName]);
Prompt.Description := Format(
'Summarizes the project currently open in the application (%s)',
[ProjectName]);
Prompt.Arguments.Add('detail', 'How detailed the summary should be');
Prompt.PromptMethod :=
function(const Args: array of TValue): TTMSMCPPromptMessages
begin
Result := TTMSMCPPromptMessages.Create(nil);
Result.AddUserMessage(Format('Summarize the project %s.'#13#10'%s',
[CurrentProjectName, CurrentProjectOutline]));
end;
end;
Because the handler runs on every list request, it must be idempotent — check
with FindByName before adding, or you will accumulate duplicates. Note that
FindByName raises on an empty name, so guard any name you build from user data.
Announcing list changes
When the set of prompts changes after a client has already listed them, the
client needs to be told. Set EnablePromptNotifications to True and the
server broadcasts notifications/prompts/list_changed whenever the collection
changes — adding or removing an item raises the collection's OnChanged, which
the server forwards for you.
Two details decide whether the notification actually goes out:
- It is opt-in. With
EnablePromptNotificationsleftFalse,SendPromptsChangedNotificationreturns without sending anything, and so does the automatic path. - Editing an existing item does not trigger it. Changing
DescriptionorTitleon a prompt already in the collection does not raise the collection'sOnChanged, so callServer.SendPromptsChangedNotificationyourself after an in-place edit.
Combining the builder, arguments, icons, and notifications
A prompt published at runtime usually uses all of these at once. This one is declared with the builder, takes one required and one optional argument, carries a title and an icon for the client's prompt list, and is added to a server that has notifications enabled so every connected client refreshes its list:
procedure TForm1.PublishIncidentPrompt;
var
Prompt: TTMSMCPPrompt;
begin
{ Notifications are opt-in; without this the server never broadcasts
notifications/prompts/list_changed. }
Server.EnablePromptNotifications := True;
Prompt := TTMSMCPPrompt.CreateBuilder
.Name('analyze_incident')
.Title('Analyze an incident')
.Description('Walks the model through an incident report and asks for a root cause')
.AddIcon('https://cdn.example.com/icons/incident-24.png', 'image/png', '24x24')
.Handler(
function(const Args: array of TValue): TTMSMCPPromptMessages
var
Audience: string;
begin
Audience := 'the engineering team';
if (Length(Args) > 1) and not Args[1].IsEmpty then
Audience := Args[1].AsString;
Result := TTMSMCPPromptMessages.Create(nil);
Result.AddUserMessage(Format(
'Here is incident report %s:'#13#10#13#10'%s',
[Args[0].AsString, LoadIncidentReport(Args[0].AsString)]));
Result.AddAssistantMessage(
'I will identify the root cause before proposing any remedy.');
Result.AddUserMessage(Format(
'Write the root-cause analysis for %s.', [Audience]));
end)
.AddArgument
.Name('incident_id')
.Description('Identifier of the incident report to analyze')
.Required(True)
.&End
.AddArgument
.Name('audience')
.Description('Who the analysis is written for, for example management')
.Required(False)
.&End
.Build;
{ Adding an item raises the collection OnChanged, which the server turns
into notifications/prompts/list_changed for every connected client. }
Server.Prompts.AddPrompt(Prompt);
end;
The handler defaults the optional argument rather than assuming it is present, which is what keeps the prompt usable when the user fills in only the required field.
Common mistakes
- Freeing the prompt returned by
Build.AddPromptcopies the declaration rather than reparenting the instance, so the built prompt is never owned by the server collection. This is the opposite ofTTMSMCPResources.AddResource, whereBuilddoes hand you the instance to free — check which collection you are working with. - Registering without declaring arguments.
RegisterPromptalone gives the client no argument list, so there is nothing for the user to fill in andArgs[0]is empty. Declare every argument the handler reads. - Inserting an argument in the middle. Arguments are positional. A new
argument added before an existing one silently shifts every later
Args[n]. - Assuming a type. There is no argument type: everything arrives as a string. Parse a number or a date yourself, and say so in the description.
- Expecting a duplicate name to be ignored.
RegisterPromptandAddPromptboth raise on a name that already exists. Guard withFindByNamewhen registering from a code path that can run twice. - Changing
Nameto improve the wording. Rename throughTitle;Nameis what clients and saved conversations refer to.
See also
- Prompt messages — what the handler returns
- Attributes — declare prompts with RTTI attributes instead
- MCP Server — hosting, sessions, and notifications
TTMSMCPPrompt,TTMSMCPPrompts,TTMSMCPPromptArgument,TTMSMCPPromptBuilder