From 24b405e15946ebe71807f1397d8007aacb670613 Mon Sep 17 00:00:00 2001 From: Girish Konda Date: Fri, 11 Sep 2026 14:28:34 -0700 Subject: [PATCH] Register List JSON metadata for Native AOT array tool arguments --- Directory.Packages.props | 1 + .../McpJsonUtilities.cs | 1 + ...xtProtocol.AotCompatibility.TestApp.csproj | 5 + .../OllamaProviderArrayRegression.cs | 132 ++++++++++++++++++ .../Program.cs | 23 +++ 5 files changed, 162 insertions(+) create mode 100644 tests/ModelContextProtocol.AotCompatibility.TestApp/OllamaProviderArrayRegression.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 565d01689..e8d8f0559 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -78,6 +78,7 @@ + diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index b193bb8de..0fe393503 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -193,6 +193,7 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => // Other MCP Types [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IReadOnlyDictionary))] + [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ProgressToken))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(Implementation))] diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index a8ab66edf..9a88d3d19 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -23,4 +23,9 @@ + + + + + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/OllamaProviderArrayRegression.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/OllamaProviderArrayRegression.cs new file mode 100644 index 000000000..52eb34ae1 --- /dev/null +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/OllamaProviderArrayRegression.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +internal sealed class OllamaProviderArrayRegression +{ + public async Task RunAsync(McpClientTool tool) + { + foreach (bool streaming in new[] { false, true }) + { + await VerifyAsync(tool, streaming, """["one","two"]""", ["one", "two"]); + await VerifyAsync(tool, streaming, "[]", []); + } + } + + private async Task VerifyAsync(McpClientTool tool, bool streaming, string itemsJson, string[] expectedItems) + { + using var handler = new ToolCallResponseHandler(itemsJson, streaming); + using var http = new HttpClient(handler); + using var provider = new Ollama.OllamaClient( + httpClient: http, + baseUri: new Uri("http://127.0.0.1:11434"), + disposeHttpClient: false); + using var client = new ObservingFunctionClient(provider) + { + MaximumConsecutiveErrorsPerRequest = 0, + }; + var messages = new[] { new ChatMessage(ChatRole.User, "Join the supplied items.") }; + var options = new ChatOptions { ModelId = "mcp-array-regression", Tools = [tool] }; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var expectedResult = $"Joined: {string.Join(',', expectedItems)}"; + using var json = JsonDocument.Parse(itemsJson); + var controlResult = await tool.InvokeAsync(new() { ["items"] = json.RootElement.Clone() }, timeout.Token); + if (controlResult is not TextContent controlText || + controlText.Text != expectedResult) + { + throw new InvalidOperationException("The already-supported JsonElement array control failed."); + } + + if (streaming) + { + await foreach (var _ in client.GetStreamingResponseAsync(messages, options, timeout.Token)) + { + } + } + else + { + await client.GetResponseAsync(messages, options, timeout.Token); + } + + if (client.InvocationCount != 1 || + handler.RequestCount != 1 || + client.Items is null || + !client.Items.SequenceEqual(expectedItems) || + client.Result != expectedResult) + { + throw new InvalidOperationException( + $"Ollama array regression failed: streaming={streaming}, expectedItems={expectedItems.Length}, " + + $"invocations={client.InvocationCount}, requests={handler.RequestCount}, result={client.Result}."); + } + + Console.WriteLine($"Ollama provider array passed: streaming={streaming}, count={expectedItems.Length}."); + } + + private sealed class ObservingFunctionClient(IChatClient provider) : FunctionInvokingChatClient(provider) + { + public int InvocationCount { get; private set; } + public string[]? Items { get; private set; } + public string? Result { get; private set; } + + protected override async ValueTask InvokeFunctionAsync( + FunctionInvocationContext context, CancellationToken cancellationToken) + { + InvocationCount++; + if (!context.Arguments.TryGetValue("items", out var value) || + value is not List items || + items.Any(item => item is not string) || + !ReferenceEquals(value, context.CallContent.Arguments?["items"])) + { + throw new InvalidOperationException("The real provider did not supply an unchanged List argument."); + } + + Items = items.Cast().ToArray(); + try + { + var result = await base.InvokeFunctionAsync(context, cancellationToken); + Result = (result as TextContent)?.Text; + return result; + } + finally + { + // The regression ends at MCP invocation, not the provider's later tool-result formatting. + context.Terminate = true; + } + } + } + + private sealed class ToolCallResponseHandler(string itemsJson, bool streaming) : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Content is null || + request.Method != HttpMethod.Post || + request.RequestUri?.AbsolutePath != "/api/chat" || + ++RequestCount != 1) + { + throw new InvalidOperationException("Unexpected provider request."); + } + + using var document = JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)); + if (document.RootElement.GetProperty("model").GetString() != "mcp-array-regression" || + document.RootElement.GetProperty("stream").GetBoolean() != streaming) + { + throw new InvalidOperationException("The provider request did not match the scenario."); + } + + var response = """ + {"model":"mcp-array-regression","created_at":"2026-08-28T16:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"Join","arguments":{"items":__ITEMS__}}}]},"done":true,"done_reason":"stop","eval_count":1,"prompt_eval_count":1} + """.Replace("__ITEMS__", itemsJson, StringComparison.Ordinal); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + response + "\n", Encoding.UTF8, streaming ? "application/x-ndjson" : "application/json"), + }; + } + } +} diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 708cd9361..d968499a7 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -41,6 +41,26 @@ throw new Exception($"Unexpected result: {result}"); } +var join = tools.FirstOrDefault(t => t.Name == "Join"); +if (join is null) +{ + throw new Exception("Expected the Join tool."); +} + +await new OllamaProviderArrayRegression().RunAsync(join); + +result = await join.InvokeAsync(new() { ["items"] = new List { "one", "two" } }); +if (result is null || !result.ToString()!.Contains("Joined: one,two")) +{ + throw new Exception($"Unexpected populated array result: {result}"); +} + +result = await join.InvokeAsync(new() { ["items"] = new List() }); +if (result is null || !result.ToString()!.Contains("Joined: ")) +{ + throw new Exception($"Unexpected empty array result: {result}"); +} + Console.WriteLine("Success!"); [McpServerToolType] @@ -49,4 +69,7 @@ internal sealed class AotTools [McpServerTool(Name = "Echo")] [McpAppUi(ResourceUri = "ui://aot/echo")] public static string Echo(string arg) => $"Echo: {arg}"; + + [McpServerTool(Name = "Join")] + public static string Join(string[] items) => $"Joined: {string.Join(',', items)}"; }