Some changes proposed

Some models (I use local ones) may frequently use different parameter name capitalization, so it needs to be made case-insensitive, so I had to change this method in Tools:

function TTMSMCPTool.SetArgs(AParams: TJSONObject): TArray;
var
Prop: TTMSMCPToolProperty;
Param: TJSONValue;

l: TTMSMCPValues;
I: Integer;
begin
SetLength(Result, FProperties.Count);
l := TTMSMCPValues.Create;
try
for I := 0 to FProperties.Count - 1 do
begin
Prop := FProperties[I];
Param := nil;

  if Assigned(AParams) then

// Param := AParams.Values[Prop.Name];
begin
// Case-insensitive lookup — models cannot be relied upon to match
// the declared parameter casing (e.g. "fileRel" vs "FileRel").
Param := AParams.Values[Prop.Name]; // fast path: exact match
if Param = nil then
for var Pair in AParams do
if SameText(Pair.JsonString.Value, Prop.Name) then
begin
Param := Pair.JsonValue;
Break;
end;
end;

  if (Param = nil) and Prop.Required then
    RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams, Format('Required parameter "%s" is missing', [Prop.Name]));

  if Param <> nil then
  begin
    try
      l.Add(Prop.Name, TValue.FromJSON(Param, Prop.PropertyType));

      if (FOwner is TTMSMCPServer) and Assigned((FOwner as TTMSMCPServer).OnBeforeExecute) then
        (FOwner as TTMSMCPServer).OnBeforeExecute(Self, AParams, l);

    except
      on E: Exception do
        RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams,
          Format('Invalid value for parameter "%s": %s', [Prop.Name, E.Message]));
    end;
  end;
end;

Result := l.ToValueArray;

finally
l.Free
end;
end;

And then with some MCP-compatible clients, a badly formatted response breaks the chain when it trickles down to some internal XML, so it has to be escaped, so I had to change this in Server (also note that all errors should nudge the model to reconsider its approach, syntax, formatting, or whatever, or it may just keep banging its head against the same wall over and over again):

function TTMSMCPCustomServer.HandleToolsCall(const Params: TJSONValue): TJSONValue;
var
JsonResult: TJSONObject;
ToolResult, NameValue, ArgumentsValue: TJSONValue;
ToolName: string;
Arguments: TJSONObject;
ContentArray: TJSONArray;
ContentItem: TJSONObject;
Tool: TTMSMCPTool;

function SanitizeErrorText(const S: string): string;
begin
Result := S
.Replace('&', '(amp)', [rfReplaceAll])
.Replace('<', '(', [rfReplaceAll])
.Replace('>', ')', [rfReplaceAll]);
end;

begin
if not FInitialized then
RaiseJsonRpcError(TTMSMCPErrorCode.ecServerNotInitialized, 'Server not initialized');

JsonResult := TJSONObject.Create;

if not (Params is TJSONObject) then
RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams, 'Invalid params');

ToolName := '';
NameValue := TJSONObject(Params).GetValue('name');
if Assigned(NameValue) and (NameValue is TJSONString) then
ToolName := TJSONString(NameValue).Value
else
RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams, 'Missing tool name');

ArgumentsValue := TJSONObject(Params).GetValue('arguments');
if Assigned(ArgumentsValue) and (ArgumentsValue is TJSONObject) then
Arguments := TJSONObject(ArgumentsValue)
else
Arguments := TJSONObject.Create;

Tool := Tools.FindByName(ToolName);

if Assigned(Tool) then
begin
try
ToolResult := Tool.ExecuteMethod(Arguments);

  JsonResult.AddPair('isError', TJSONBool.Create(False));
  ContentArray := TJSONArray.Create;
  ContentItem := TJSONObject.Create;
  ContentItem.AddPair('type', 'text');
  ContentItem.AddPair('text', ToolResult.ToString);
  ContentArray.Add(ContentItem);
  JsonResult.AddPair('content', ContentArray);

  ToolResult.Free;
  Exit(JsonResult);
except
  on E: Exception do
  begin
    var ReceivedArgs := '';
    if Assigned(Arguments) then
      ReceivedArgs := SanitizeErrorText(Arguments.ToJSON);

    var ErrMsg :=
      'Error executing tool ' + ToolName + ': ' +
      SanitizeErrorText(E.Message) + #10 +
      'Arguments received: ' + ReceivedArgs + #10 +
      'Please retry with corrected arguments.';

    JsonResult.AddPair('isError', TJSONBool.Create(True));
    ContentArray := TJSONArray.Create;
    ContentItem := TJSONObject.Create;
    ContentItem.AddPair('type', 'text');
    ContentItem.AddPair('text', ErrMsg);
    ContentArray.Add(ContentItem);
    JsonResult.AddPair('content', ContentArray);
    Exit(JsonResult);
  end;
end;

end;

JsonResult.AddPair('isError', TJSONBool.Create(True));
ContentArray := TJSONArray.Create;
ContentItem := TJSONObject.Create;
ContentItem.AddPair('type', 'text');
ContentItem.AddPair('text', Format('Unknown tool: %s', [ToolName]));
ContentArray.Add(ContentItem);
JsonResult.AddPair('content', ContentArray);
Exit(JsonResult);
end;

I would appreciate it if you can review and plug these fixes into your components.

TIA, Alex.

My culprit was actually in the “Attributed” unit, with RTTI, so there’s another fix:

function TTMSMCPRttiTool.SetArgs(AParams: TJSONObject): TArray;
var
AMethod: TRttiMethod;
rType: TRttiType;
ctx: TRttiContext;
Parameters: TArray;
Prop: TTMSMCPToolProperty;
Param: TJSONValue;
l: TTMSMCPValues;
I, J: Integer;
ParamFound: Boolean;
Attr: TCustomAttribute;
OptAttr: ITMSMCPOptionalAttribute;
begin
ctx := TRttiContext.Create;
l := TTMSMCPValues.Create;
try
rType := ctx.GetType(FClass);
if Assigned(rType) then
begin
AMethod := GetAttributedMethod(FMethodName, rType);
if Assigned(AMethod) then
begin
Parameters := AMethod.GetParameters;

    // Process all parameters from the method signature
    for J := 0 to Length(Parameters) - 1 do
    begin
      ParamFound := False;

      // Try to find matching property
      for I := 0 to Properties.Count - 1 do
      begin
        Prop := Properties[I];
        if (Prop as TTMSMCPRttiToolProperty).FParameterName = Parameters[J].Name then
        begin
          Param := nil;
          if Assigned(AParams) then
          begin
            // Case-insensitive lookup - models cannot be relied upon to match
            // declared parameter casing (e.g. "fileRel" vs "FileRel").
            Param := AParams.Values[Prop.Name];  // fast path: exact match
            if Param = nil then
              for var K := 0 to AParams.Count - 1 do
                if SameText(AParams.Pairs[K].JsonString.Value, Prop.Name) then
                begin
                  Param := AParams.Pairs[K].JsonValue;
                  Break;
                end;
          end;

          if (Param = nil) and Prop.Required then
            RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams,
              Format('Required parameter "%s" is missing', [Prop.Name]));

          if Param <> nil then
          begin
            try
              // Coerce JSON strings to the declared RTTI type - models sometimes
              // send numbers and booleans as quoted strings (e.g. "50" for integer).
              if Param is TJSONString then
              begin
                var S := TJSONString(Param).Value;
                var TypeKind := Parameters[J].ParamType.TypeKind;
                if TypeKind in [tkInteger, tkInt64] then
                begin
                  var N: Int64;
                  if TryStrToInt64(S, N) then
                    Param := TJSONNumber.Create(N)
                  else
                    RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams,
                      Format('Parameter "%s" expects an integer, got "%s"', [Prop.Name, S]));
                end
                else if TypeKind in [tkFloat] then
                begin
                  var F: Double;
                  if TryStrToFloat(S, F) then
                    Param := TJSONNumber.Create(F)
                  else
                    RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams,
                      Format('Parameter "%s" expects a number, got "%s"', [Prop.Name, S]));
                end
                else if TypeKind in [tkEnumeration] then
                begin
                  // Boolean is tkEnumeration in Delphi RTTI
                  if Parameters[J].ParamType.Handle = TypeInfo(Boolean) then
                  begin
                    if SameText(S, 'true') or (S = '1') then
                      Param := TJSONBool.Create(True)
                    else if SameText(S, 'false') or (S = '0') then
                      Param := TJSONBool.Create(False)
                    else
                      RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams,
                        Format('Parameter "%s" expects a boolean, got "%s"', [Prop.Name, S]));
                  end;
                end;
              end;

              l.Add(Parameters[J].Name, TJSONParser.JSONToTValue(Param, Parameters[J].ParamType));
              if Owner is TTMSMCPServer and Assigned((Owner as TTMSMCPServer).OnBeforeExecute) then
                (Owner as TTMSMCPServer).OnBeforeExecute(Self, AParams, l);
              ParamFound := True;
            except
              on E: Exception do
                RaiseJsonRpcError(TTMSMCPErrorCode.ecInvalidParams,
                  Format('Invalid value for parameter "%s": %s', [Prop.Name, E.Message]));
            end;
          end;

          Break;
        end;
      end;

      for Attr in Parameters[J].GetAttributes do
      begin
        if not ParamFound and Supports(Attr, TMS_MCP_OPTIONAL_ATTRIBUTE, OptAttr)  then
        begin
          l.Add(Parameters[J].Name, OptAttr.Value);
        end;
      end;
    end;

    Result := l.ToValueArray;
  end
  else
  begin
    // Fallback if method not found - return empty array
    SetLength(Result, 0);
  end;
end
else
begin
  // Fallback if type not found - return empty array
  SetLength(Result, 0);
end;

finally
l.Free;
ctx.Free;
end;
end;

PS: All models have different quirks, so you may potentially never see such issues with much bigger models like Claude, but smaller models can really respond with any kind of garbage, please keep an eye on this.

Hi,

Thank you for your suggestions. We’ll review and test these changes in due time.

We usually test with larger models, so if you know of smaller models where these limitations occur, please let us know. This helps us make our products as reliable as possible.

I use a few, but qwen3-coder-next under Ollama seems to be especially likely to use wrong letter cases. And my client was OLLMCP for this test - that’s where the XML errors would pop up unless the error messages are sanitized and extended with helpful advises.