Table of Contents

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 nil is not an error. TTMSMCPPrompt.Execute substitutes an empty collection, so the client receives a prompt with no messages rather than a failure.
  • The prompt's Description is copied into the prompts/get result 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:

  1. Checks that a handler is assigned.
  2. Rejects the call if any argument declared Required is absent from the JSON object.
  3. Converts each declared argument, in declaration order, into one TValue in the array the handler receives — always as a string, with an omitted optional argument becoming an empty TValue.
  4. Calls Execute, then builds the result object: the prompt's Description, 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.Free around the build is a dangling reference in the serializer.
  • Using a system role. Role accepts 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].AsString on an empty TValue does not give you the default you meant. Test IsEmpty first.
  • 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. OnCompletion only fires when EnableCompletions is True; the capability is advertised during initialization, so set it before the server starts.

See also