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
OnTaskExecuteunassigned and the server auto-executes the named tool on the worker thread and completes the task with its result. SettingEnableTasks := Trueis then the only code required. - Assign
OnTaskExecuteand 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.
OnTaskCancelis a request, not a stop. PollGetTask(ATaskId).Statusin long loops or the work runs to completion anyway. - Using
RequestElicitationinside a task. UseRequestTaskElicitationso the task moves totsInputRequiredand 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
SetTaskResultorSetTaskErrorthe task sits attsWorkinguntil its TTL expires and it is purged.
See also
- Configuring the server — the capability flags these features need
- Sessions and state — tasks and elicitation are session-scoped
- Tools —
TaskSupporton the tool side TTMSMCPTask,TTMSMCPElicitRequest,TTMSMCPSamplingRequest