Table of Contents

Tasks and elicitation

Most tool calls answer in milliseconds. Some do not: a reindex, a bulk import, a report over a year of data. And some cannot answer at all without asking a question first — which carrier, overwrite or merge, are you sure. Both cases break the simple request/response shape, and the protocol has an answer for each. Tasks let a call return a handle immediately and finish on a worker thread while the client polls. Elicitation lets the server pause mid-request and ask the connected client for input through a small typed form. Sampling is the mirror image: the server asks the client to run a model completion on its behalf. This guide covers all three.

Long-running tasks

Tasks arrived with protocol version 2025-11-25, so both EnableTasks and a client negotiated at that version are required. With them in place, a tools/call whose body carries a task block is not executed inline: the server creates a task, fires OnTaskCreate, starts the work on a background thread, and answers immediately with a handle carrying the task id, status, poll interval, and TTL.

There are two ways to run the work, and the choice is made by whether you assign one event:

  • Leave OnTaskExecute unassigned and the server auto-executes the named tool on the worker thread and completes the task with its result. Setting EnableTasks := True is then the only code required.
  • Assign OnTaskExecute and it fires on the task's worker thread with (ATaskId, AToolName, AArguments). You do the work and finish the task yourself.
procedure TForm1.ConfigureTasks;
begin
  Server.EnableTasks := True;
  Server.DefaultTaskPollInterval := 3000;
  Server.OnTaskExecute := ServerTaskExecute;
  Server.OnTaskCancel := ServerTaskCancel;
end;

procedure TForm1.ServerTaskExecute(Sender: TObject;
  const ATaskId, AToolName: string; const AArguments: TJSONObject);
var
  Total, I: Integer;
  Payload: TJSONObject;
begin
  { Runs on the task's own worker thread, not the main thread. }
  Total := CountWarehouses;
  for I := 0 to Total - 1 do
  begin
    if Server.GetTask(ATaskId).Status = tsCancelled then
      Exit;

    RecountWarehouse(I);
    Server.UpdateTaskStatus(ATaskId, tsWorking,
      Format('Counted %d of %d warehouses', [I + 1, Total]));
  end;

  Payload := TJSONObject.Create;
  Payload.AddPair('warehouses', TJSONNumber.Create(Total));
  Server.SetTaskResult(ATaskId, Payload);
end;

procedure TForm1.ServerTaskCancel(Sender: TObject; const ATaskId: string);
begin
  AbortRecount(ATaskId);
end;

Four methods drive a task, and they are callable from anywhere — including a thread of your own that owns the real work:

Method Effect
UpdateTaskStatus(ATaskId, AStatus, AMessage) Sets status and a human-readable message. Use it for progress.
SetTaskResult(ATaskId, AResult) Stores the result and sets the status to tsCompleted.
SetTaskError(ATaskId, AError) Stores the error and sets the status to tsFailed.
GetTask(ATaskId) Returns the live TTMSMCPTask — read Status to check for cancellation.

TTMSMCPTaskStatus is tsWorking, tsInputRequired, tsCompleted, tsFailed, tsCancelled. Every status change sends a notifications/tasks/status_changed to the task's owning session.

Tasks are session-scoped. A client can list, poll, and cancel only its own; another session's task id comes back as a generic "Task not found" rather than confirming that the task exists. DefaultTaskPollInterval sets how often the server suggests the client polls, and a client may override both that and the TTL in its own task block. Expired tasks are purged automatically.

Cancellation is cooperative. OnTaskCancel tells you the client asked to stop; the work does not end until your handler notices, which is why the example checks GetTask(ATaskId).Status on each pass of its loop.

Elicitation

Elicitation asks the client — the MCP host application and its user — for information, not the model. The request carries a message and a small JSON-schema-described form, and blocks until an answer comes back:

function TForm1.ConfirmShipment(const AOrderId: string): Boolean;
var
  Request: TTMSMCPElicitRequest;
  Answer: TTMSMCPElicitResult;
  Field: TTMSMCPElicitField;
begin
  Result := False;

  Request := TTMSMCPElicitRequest.Create;
  try
    Request.Message := Format('Ship order %s now?', [AOrderId]);

    Field := Request.Schema.AddField('carrier', eftEnum);
    Field.Title := 'Carrier';
    Field.Required := True;
    Field.EnumValues.Add('road');
    Field.EnumValues.Add('air');
    Field.EnumValues.Add('sea');

    Field := Request.Schema.AddField('insured', eftBoolean);
    Field.Title := 'Add insurance';
    Field.DefaultValue := 'false';

    { Blocks until the client answers, declines, or cancels. }
    Answer := Server.RequestElicitation(Request);
    try
      Result := (Answer <> nil) and (Answer.Action = 'accept');
      if Result then
        ShipOrder(AOrderId, Answer.Content);
    finally
      Answer.Free;
    end;
  finally
    Request.Free;
  end;
end;

A TTMSMCPElicitRequest has a Message, a Schema, and optional Mode, URL, and ElicitationId. Fields are added with Schema.AddField(AName, AType), which returns the field so you can set Title, Description, Required, DefaultValue, and — for an enum field — fill EnumValues. TTMSMCPElicitFieldType is eftString, eftNumber, eftBoolean, and eftEnum.

The result's Action is 'accept', 'decline', or 'cancel'. Only on 'accept' is Content populated with the submitted values. Treat decline and cancel as ordinary outcomes — the user saying no is not an error.

EnableElicitation must be on for the capability to be advertised. OnElicitationRequest and OnElicitationComplete let you log or intercept the round-trip.

Elicitation from inside a task

A task that needs an answer cannot use the plain call, because the task's status has to reflect that it is waiting. RequestTaskElicitation(ATaskId, ARequest) handles that: it moves the task to tsInputRequired, pushes the status change, performs the blocking round-trip, and moves the task back to tsWorking afterwards — even if the round-trip raises, so a task never gets stuck in input-required after an error or a decline. If the client cancels while the thread is blocked on the answer, the task stays cancelled rather than being resurrected. Completing the task is still yours to do with SetTaskResult or SetTaskError.

Sampling

Sampling runs the other way: the server asks the client to perform a model completion on its behalf, so the server can use a model without holding API keys or choosing a provider. EnableSampling advertises the capability, and RequestSampling(ARequest) performs the round-trip with a TTMSMCPSamplingRequest — messages, model preferences, context inclusion, and stop conditions — returning a TTMSMCPSamplingResult. OnSamplingRequest lets you observe or override the exchange.

Roots

Roots run the same direction as sampling — server to client — but ask for context rather than for work. A root is a folder or workspace the client has decided this server may see, and RequestRootsList fetches the current set as an array of TTMSMCPRoot records, each carrying a Name and a URI:

procedure TForm1.ConfigureRoots;
begin
  Server.OnRootsChanged := ServerRootsChanged;

  Server.Tools.RegisterTool('list_workspace_roots',
    'Lists the folders the client has made available to this server',
    function(const Args: array of TValue): TValue
    var
      Roots: TArray<TTMSMCPRoot>;
      Names: TStringList;
      I: Integer;
    begin
      { RequestRootsList is a round-trip to the client, so it has to run on the
        thread handling a request - a tool handler is the usual place. }
      Roots := Server.RequestRootsList;

      Names := TStringList.Create;
      try
        for I := 0 to Length(Roots) - 1 do
          Names.Add(Format('%s (%s)', [Roots[I].Name, Roots[I].URI]));

        if Names.Count = 0 then
          Result := 'The client exposes no roots.'
        else
          Result := Names.Text;
      finally
        Names.Free;
      end;
    end,
    ptString);
end;

procedure TForm1.ServerRootsChanged(Sender: TObject);
begin
  { The client's root list changed, so anything cached from an earlier
    RequestRootsList call is stale. Ask again on the next request. }
  FRootsCacheValid := False;
end;

Two constraints shape how it is used. It is a blocking round-trip to the client, so like CurrentSessionState it belongs on the thread that is handling a request — calling it outside one raises "Server not initialized". And roots are a client capability, not a server one: there is no flag to enable, and a client that does not offer roots simply answers with nothing, so the result comes back as an empty array rather than as an error.

OnRootsChanged fires when the client sends notifications/roots/list_changed. It carries no payload — it is a signal that any list you cached is stale, and the next request should ask again.

Combining tasks, elicitation, progress, and logging

The three features are designed to compose. A bulk import is long enough to need a task, destructive enough to need confirmation before it starts, slow enough to need progress, and important enough to log:

procedure TForm1.ConfigureBulkImport;
begin
  Server.EnableTasks := True;
  Server.EnableElicitation := True;
  Server.EnableLogging := True;
  Server.OnTaskExecute := BulkImportExecute;
end;

procedure TForm1.BulkImportExecute(Sender: TObject;
  const ATaskId, AToolName: string; const AArguments: TJSONObject);
var
  Request: TTMSMCPElicitRequest;
  Answer: TTMSMCPElicitResult;
  Field: TTMSMCPElicitField;
  Rows, I: Integer;
  Overwrite: Boolean;
  Payload: TJSONObject;
begin
  Rows := CountImportRows(AArguments);

  { Ask before doing anything destructive. RequestTaskElicitation moves the
    task to tsInputRequired for the round-trip and back to tsWorking after,
    even if the client declines. }
  Request := TTMSMCPElicitRequest.Create;
  try
    Request.Message := Format('%d rows will be imported. Overwrite existing records?', [Rows]);
    Field := Request.Schema.AddField('overwrite', eftBoolean);
    Field.Title := 'Overwrite existing records';
    Field.Required := True;

    Answer := Server.RequestTaskElicitation(ATaskId, Request);
    try
      if (Answer = nil) or (Answer.Action <> 'accept') then
      begin
        Server.SendLogMessage(llNotice, 'import', 'Import declined by the user');
        Server.UpdateTaskStatus(ATaskId, tsCancelled, 'Declined by the user');
        Exit;
      end;
      Overwrite := ReadBoolean(Answer.Content, 'overwrite');
    finally
      Answer.Free;
    end;
  finally
    Request.Free;
  end;

  for I := 0 to Rows - 1 do
  begin
    ImportRow(I, Overwrite);
    Server.UpdateTaskStatus(ATaskId, tsWorking,
      Format('Imported %d of %d rows', [I + 1, Rows]));
  end;

  Server.SendLogMessage(llInfo, 'import',
    Format('Imported %d rows (overwrite=%s)', [Rows, BoolToStr(Overwrite, True)]));

  Payload := TJSONObject.Create;
  Payload.AddPair('rows', TJSONNumber.Create(Rows));
  Payload.AddPair('overwrite', TJSONBool.Create(Overwrite));
  Server.SetTaskResult(ATaskId, Payload);
end;

Note the order. The question is asked first, through RequestTaskElicitation so the task's status reflects the wait; a decline ends the task as tsCancelled rather than as a failure; progress goes out through UpdateTaskStatus on each row; and the audit line goes to the client's log stream only once the work is done.

Common mistakes

  • Enabling tasks and expecting old clients to use them. Tasks require protocol 2025-11-25. A client negotiated lower will not see the capability.
  • Ignoring cancellation. OnTaskCancel is a request, not a stop. Poll GetTask(ATaskId).Status in long loops or the work runs to completion anyway.
  • Using RequestElicitation inside a task. Use RequestTaskElicitation so the task moves to tsInputRequired and back, and cannot be left stuck waiting.
  • Touching the UI from OnTaskExecute. It runs on a worker thread. Synchronise before touching a control.
  • Treating 'decline' as an error. Decline and cancel are normal answers. Only a missing or malformed response is a failure.
  • Confusing elicitation with sampling. Elicitation asks the user a question; sampling asks the client to run a model. They are opposite directions.
  • Never finishing a task. Without SetTaskResult or SetTaskError the task sits at tsWorking until its TTL expires and it is purged.

See also