Table of Contents

Tool results

What a tool sends back is as much a part of its contract as what it accepts. A plain string is fine for a greeting, but a model consuming a customer record, a generated file, or a long-running rebuild needs more: typed fields it can read without parsing prose, links to content it can fetch separately, and a way to start work that will not finish inside one request. This guide covers the return type, structured output described by a JSON schema, native MCP content items, and opt-in execution as a long-running task.

The return type

ReturnType declares what the method's TValue result holds, using the same TTMSMCPToolPropertyType values as input properties. The framework converts the TValue to JSON accordingly, so a ptFloat tool returning `Args[0].AsExtended

  • Args[1].AsExtended` arrives at the client as a JSON number rather than a string.

Three execution callbacks are available, and a tool uses one of them:

Callback Signature Arguments arrive as
ExecuteCallback / OnExecute TTMSMCPMethod / TTMSMCPMethodEvent array of TValue, positional
DynamicExecuteCallback / OnDynamicExecute TTMSMCPDynamicMethod / TTMSMCPDynamicMethodEvent TDictionary<string, TValue>, by name
StructuredExecuteCallback TTMSMCPDynamicStructuredMethod TDictionary<string, TValue>, by name; returns TJSONObject

Prefer the dynamic forms once a tool has more than two or three arguments — Args['customer_id'] survives a reordered property list, Args[0] does not.

Structured output

A tool that returns a record should say so. OutputSchema takes a JSON schema string, and StructuredExecuteCallback returns a TJSONObject matching it. The client then receives typed fields instead of text it has to parse:

procedure TForm1.RegisterLookupCustomerTool;
var
  Tool: TTMSMCPTool;
begin
  Tool := TTMSMCPTool.CreateBuilder
    .Name('lookup_customer')
    .Description('Looks up a customer and returns their record')
    .ReadOnlyHint(True)
    .OutputSchema(
      '{"type":"object","properties":{' +
      '"name":{"type":"string"},' +
      '"city":{"type":"string"},' +
      '"balance":{"type":"number"}},' +
      '"required":["name"]}')
    .StructuredExecuteCallback(
      function(const Args: TDictionary<string, TValue>): TJSONObject
      var
        Id: string;
      begin
        Id := Args['customer_id'].AsString;
        Result := TJSONObject.Create;
        Result.AddPair('name', LookupName(Id));
        Result.AddPair('city', LookupCity(Id));
        Result.AddPair('balance', TJSONNumber.Create(LookupBalance(Id)));
      end)
    .AddProperty
      .Name('customer_id')
      .Description('Customer identifier')
      .PropertyType(ptString)
      .Required(True)
      .&End
    .Build;

  Server.Tools.Add(Tool);
end;

The schema is advertised with the tool, so a model can plan around the shape of the answer before calling. Keep required honest — a field you list but do not always populate makes the schema a lie.

For a schema derived from an existing Delphi type rather than written by hand, use TTMSMCPJSONSchemaGenerator with TTMSMCPJSONSchemaOptions; OnGenerateInputSchema gives the same control over the input schema.

Native content items

MCP defines content items — text, image, audio, resource, and resource link — that a tool can return as a JSON array instead of a single scalar. TTMSMCPContent builds them:

Method Produces
TTMSMCPContent.Text(AText) A text content item.
TTMSMCPContent.ResourceLink(AURI, AName, ADescription, AMimeType, ATitle) A link to a resource the client can fetch separately.
TTMSMCPContent.ContentArray([…]) The array wrapping several content items.

This is the right shape when the answer is "here is a summary, and here is where the actual artefact lives" — the model reads the text, and the client fetches the file only if it needs it:

procedure TForm1.RegisterBuildReportTool;
var
  Tool: TTMSMCPTool;
begin
  Tool := TTMSMCPTool.CreateBuilder
    .Name('build_report')
    .Description('Builds a report and returns a summary plus a link to the file')
    .ReturnType(ptJSON)
    .ExecuteCallback(
      function(const Args: array of TValue): TValue
      var
        URI: string;
        Content: TJSONArray;
      begin
        URI := BuildReport(Args[0].AsString);
        Content := TTMSMCPContent.ContentArray([
          TTMSMCPContent.Text('The report is ready.'),
          TTMSMCPContent.ResourceLink(URI, 'report.pdf',
            'Generated report', 'application/pdf')
        ]);
        Result := Content.ToJSON;
      end)
    .AddProperty
      .Name('period')
      .Description('Reporting period, for example 2026-Q1')
      .PropertyType(ptString)
      .Required(True)
      .&End
    .Build;

  Server.Tools.Add(Tool);
end;

A resource link points at something the Resources component serves, so the URI should be one the same server can resolve.

Long-running tasks

Some work does not fit in a request. TaskSupport opts a tool into the MCP tasks capability:

Value Meaning
tsForbidden The tool never runs as a task. This is the default.
tsOptional The client may run the call as a task, or inline.
tsRequired The call must be run as a task.

With tsOptional or tsRequired, the client gets a task id back immediately and polls for status, and the server can report progress, accept cancellation, and even ask the user a question mid-task through elicitation. The server side of that lifecycle — OnTaskExecute, SetTaskResult, SetTaskError, and RequestTaskElicitation — belongs to TTMSMCPServer and is covered in the MCP Server guides.

Setting TaskSupport alone does not make a tool asynchronous. It advertises the capability; the server's task handling is what actually runs the work off the request.

Combining output schema, hints, enums, and task support

A production tool usually uses several of these at once. This one constrains its argument to a known set, promises idempotence so a client may safely retry, describes its result with an output schema, and allows the client to run it as a task because a reindex can take minutes:

procedure TForm1.RegisterReindexTool;
var
  Tool: TTMSMCPTool;
begin
  Tool := TTMSMCPTool.CreateBuilder
    .Name('reindex_catalog')
    .Title('Reindex catalog')
    .Description('Rebuilds the search index for one catalog section')
    .DestructiveHint(False)
    .IdempotentHint(True)
    .OpenWorldHint(False)
    .TaskSupport(tsOptional)
    .OutputSchema(
      '{"type":"object","properties":{' +
      '"section":{"type":"string"},' +
      '"documents":{"type":"integer"},' +
      '"seconds":{"type":"number"}}}')
    .StructuredExecuteCallback(
      function(const Args: TDictionary<string, TValue>): TJSONObject
      var
        Section: string;
        Started: TDateTime;
        Count: Integer;
      begin
        Section := Args['section'].AsString;
        Started := Now;
        Count := ReindexSection(Section);

        Result := TJSONObject.Create;
        Result.AddPair('section', Section);
        Result.AddPair('documents', TJSONNumber.Create(Count));
        Result.AddPair('seconds',
          TJSONNumber.Create(SecondSpan(Started, Now)));
      end)
    .AddProperty
      .Name('section')
      .Description('Catalog section to reindex')
      .PropertyType(ptString)
      .AddEnums(['products', 'articles', 'media'])
      .Required(True)
      .&End
    .Build;

  Server.Tools.Add(Tool);
end;

Each declaration answers a different question a client has to resolve before calling: what may I send, is a retry safe, what will I get back, and how long will this take.

Common mistakes

  • An output schema with no structured callback. OutputSchema describes the result; StructuredExecuteCallback produces it. Declaring the schema while returning a plain string through ExecuteCallback advertises a contract the tool does not honour.
  • Freeing the TJSONObject you return. The result is handed to the framework, which serialises and releases it. Freeing it in the callback leaves the caller with a dangling reference.
  • Expecting TaskSupport to make the call asynchronous by itself. It advertises the capability. Without server-side task handling the work still runs inline.
  • Positional arguments on a wide tool. Args[3] silently becomes the wrong value the day someone reorders the property list. Use the dynamic callbacks and read arguments by name.

See also