From c4862072fdae8e6dbeecefb36556b2ac5c5b8042 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 11 Jul 2025 09:56:34 -0400 Subject: [PATCH 1/5] Bump FunctionInvokingChatClient.MaximumIterationsPerRequest from 10 to 40 (#6599) Folks are bumping up against the arbitrary limit of 10, as various modern models are super chatty with tools. While we need a limit to avoid runaway execution, we can make it much higher. --- .../ChatCompletion/FunctionInvokingChatClient.cs | 4 ++-- .../ChatCompletion/FunctionInvokingChatClientTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 6b1d3b3e905..0a8673dc91d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -61,7 +61,7 @@ public partial class FunctionInvokingChatClient : DelegatingChatClient private readonly ActivitySource? _activitySource; /// Maximum number of roundtrips allowed to the inner client. - private int _maximumIterationsPerRequest = 10; + private int _maximumIterationsPerRequest = 40; // arbitrary default to prevent runaway execution /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. private int _maximumConsecutiveErrorsPerRequest = 3; @@ -142,7 +142,7 @@ public static FunctionInvocationContext? CurrentContext /// /// /// The maximum number of iterations per request. - /// The default value is 10. + /// The default value is 40. /// /// /// diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 1379cef8bf0..b4ce2f1546c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -36,7 +36,7 @@ public void Ctor_HasExpectedDefaults() Assert.False(client.AllowConcurrentInvocation); Assert.False(client.IncludeDetailedErrors); - Assert.Equal(10, client.MaximumIterationsPerRequest); + Assert.Equal(40, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); Assert.Null(client.FunctionInvoker); } @@ -55,7 +55,7 @@ public void Properties_Roundtrip() client.IncludeDetailedErrors = true; Assert.True(client.IncludeDetailedErrors); - Assert.Equal(10, client.MaximumIterationsPerRequest); + Assert.Equal(40, client.MaximumIterationsPerRequest); client.MaximumIterationsPerRequest = 5; Assert.Equal(5, client.MaximumIterationsPerRequest); From f2772086138a1f2a5316531182188f861cc8f236 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 11 Jul 2025 17:27:11 -0400 Subject: [PATCH 2/5] Expose M.E.AI.OpenAI input message conversions (#6601) Internally we have helpers that convert from M.E.AI chat messages to the various OpenAI object models. To ease interop when a developer gets M.E.AI messages from another library and then wants to submit them on their own to OpenAI, this just exposes those helpers publicly. --- .../OpenAIChatClient.cs | 10 +- .../OpenAIClientExtensions.cs | 12 ++ .../OpenAIResponseChatClient.cs | 4 +- .../OpenAIAIFunctionConversionTests.cs | 77 -------- .../OpenAIConversionTests.cs | 186 ++++++++++++++++++ 5 files changed, 205 insertions(+), 84 deletions(-) delete mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 3be0a1cc1ee..394fccad1b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -70,7 +70,7 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. @@ -85,7 +85,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. @@ -115,7 +115,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. - private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions) + internal static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions) { // Maps all of the M.E.AI types to the corresponding OpenAI types. // Unrecognized or non-processable content is ignored. @@ -148,7 +148,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op { try { - result = JsonSerializer.Serialize(resultContent.Result, jsonOptions.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -176,7 +176,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op case FunctionCallContent fc: (toolCalls ??= []).Add( ChatToolCall.CreateFunctionToolCall(fc.CallId, fc.Name, new(JsonSerializer.SerializeToUtf8Bytes( - fc.Arguments, jsonOptions.GetTypeInfo(typeof(IDictionary)))))); + fc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); break; default: diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index dccddf3038e..9f42fa88773 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -181,6 +181,18 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI chat messages. + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI response items. + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => + OpenAIResponseChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); + // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. /// Gets whether the properties specify that strict schema handling is desired. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 6aee4bc77e4..c4a1261844c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -17,6 +17,7 @@ #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -466,8 +467,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt } /// Convert a sequence of s to s. - private static IEnumerable ToOpenAIResponseItems( - IEnumerable inputs) + internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs) { foreach (ChatMessage input in inputs) { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs deleted file mode 100644 index ce458473c59..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.ComponentModel; -using System.Text.Json; -using OpenAI.Assistants; -using OpenAI.Chat; -using OpenAI.Realtime; -using OpenAI.Responses; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OpenAIAIFunctionConversionTests -{ - private static readonly AIFunction _testFunction = AIFunctionFactory.Create( - ([Description("The name parameter")] string name) => name, - "test_function", - "A test function for conversion"); - - [Fact] - public void AsOpenAIChatTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIChatTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.FunctionDescription); - ValidateSchemaParameters(tool.FunctionParameters); - } - - [Fact] - public void AsOpenAIResponseTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIResponseTool(); - - Assert.NotNull(tool); - } - - [Fact] - public void AsOpenAIConversationFunctionTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIConversationFunctionTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.Name); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - [Fact] - public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - /// Helper method to validate function parameters match our schema. - private static void ValidateSchemaParameters(BinaryData parameters) - { - Assert.NotNull(parameters); - - using var jsonDoc = JsonDocument.Parse(parameters); - var root = jsonDoc.RootElement; - - Assert.Equal("object", root.GetProperty("type").GetString()); - Assert.True(root.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("name", out var nameProperty)); - Assert.Equal("string", nameProperty.GetProperty("type").GetString()); - Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs new file mode 100644 index 00000000000..951554eda75 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -0,0 +1,186 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using OpenAI.Assistants; +using OpenAI.Chat; +using OpenAI.Realtime; +using OpenAI.Responses; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIConversionTests +{ + private static readonly AIFunction _testFunction = AIFunctionFactory.Create( + ([Description("The name parameter")] string name) => name, + "test_function", + "A test function for conversion"); + + [Fact] + public void AsOpenAIChatTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIChatTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.FunctionDescription); + ValidateSchemaParameters(tool.FunctionParameters); + } + + [Fact] + public void AsOpenAIResponseTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIResponseTool(); + + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIConversationFunctionTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIConversationFunctionTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.Name); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + [Fact] + public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + /// Helper method to validate function parameters match our schema. + private static void ValidateSchemaParameters(BinaryData parameters) + { + Assert.NotNull(parameters); + + using var jsonDoc = JsonDocument.Parse(parameters); + var root = jsonDoc.RootElement; + + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("name", out var nameProperty)); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); + } + + [Fact] + public void AsOpenAIChatMessages_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIChatMessages()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Equal(5, convertedMessages.Length); + + SystemChatMessage m0 = Assert.IsType(convertedMessages[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + UserChatMessage m1 = Assert.IsType(convertedMessages[1]); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + AssistantChatMessage m2 = Assert.IsType(convertedMessages[2]); + Assert.Single(m2.Content); + Assert.Equal("Hi there!", m2.Content[0].Text); + var tc = Assert.Single(m2.ToolCalls); + Assert.Equal("callid123", tc.Id); + Assert.Equal("SomeFunction", tc.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); + + ToolChatMessage m3 = Assert.IsType(convertedMessages[3]); + Assert.Equal("callid123", m3.ToolCallId); + Assert.Equal("theresult", Assert.Single(m3.Content).Text); + + AssistantChatMessage m4 = Assert.IsType(convertedMessages[4]); + Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); + } + + [Fact] + public void AsOpenAIResponseItems_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIResponseItems()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedItems = messages.AsOpenAIResponseItems().ToArray(); + + Assert.Equal(6, convertedItems.Length); + + MessageResponseItem m0 = Assert.IsAssignableFrom(convertedItems[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + MessageResponseItem m1 = Assert.IsAssignableFrom(convertedItems[1]); + Assert.Equal(OpenAI.Responses.MessageRole.User, m1.Role); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + MessageResponseItem m2 = Assert.IsAssignableFrom(convertedItems[2]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m2.Role); + Assert.Equal("Hi there!", Assert.Single(m2.Content).Text); + + FunctionCallResponseItem m3 = Assert.IsAssignableFrom(convertedItems[3]); + Assert.Equal("callid123", m3.CallId); + Assert.Equal("SomeFunction", m3.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(m3.FunctionArguments.ToMemory().Span))); + + FunctionCallOutputResponseItem m4 = Assert.IsAssignableFrom(convertedItems[4]); + Assert.Equal("callid123", m4.CallId); + Assert.Equal("theresult", m4.FunctionOutput); + + MessageResponseItem m5 = Assert.IsAssignableFrom(convertedItems[5]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role); + Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text); + } +} From 64b7f2a07e3f02f6dcd3738be166af96724b660a Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Mon, 14 Jul 2025 13:41:42 -0400 Subject: [PATCH 3/5] Add schema version to server.json in MCP template (#6606) * Add schema version to server.json * Fix test --- .../src/McpServer/McpServer-CSharp/.mcp/server.json | 1 + .../mcpserver.Basic.verified/mcpserver/.mcp/server.json | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json index d4b9d0edf5b..34c19714f79 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json @@ -1,4 +1,5 @@ { + "$schema": "https://modelcontextprotocol.io/schemas/draft/2025-07-09/server.json", "description": "", "name": "io.github./", "packages": [ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json index ab997541e52..02908c09afb 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json @@ -1,4 +1,5 @@ { + "$schema": "https://modelcontextprotocol.io/schemas/draft/2025-07-09/server.json", "description": "", "name": "io.github./", "packages": [ From 09c10756389ff9a65c1e1672812362b61650d48e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:16:42 -0700 Subject: [PATCH 4/5] Update MCP server template readme to show both VS Code and Visual Studio notes (#6591) * Initial plan * Add VS IDE-specific README and update template configuration Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> * Update to single README with both VS Code and Visual Studio sections Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> * Fix Visual Studio MCP documentation URL Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> * Fix Visual Studio MCP JSON configuration format Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> * Restructure README to reduce repetition between VS Code and Visual Studio sections Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> * Eliminate repetitive JSON configuration in README by consolidating server definitions Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> * Revise the mcp server template README * Update MCP template README paths and sync snapshot with source template Co-authored-by: joelverhagen <94054+joelverhagen@users.noreply.github.com> * Update mcpserver project template baseline * Bump MEAI.Templates package to preview.3. * Add feedback survey to mcpserver project template README --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> Co-authored-by: Jeff Handley Co-authored-by: joelverhagen <94054+joelverhagen@users.noreply.github.com> --- .../Microsoft.Extensions.AI.Templates.csproj | 2 +- .../src/McpServer/McpServer-CSharp/README.md | 85 ++++++++++--------- .../mcpserver/README.md | 85 ++++++++++--------- 3 files changed, 91 insertions(+), 81 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 7784747028e..ab5ef554a3a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -7,7 +7,7 @@ dotnet-new;templates;ai preview - 2 + 3 AI 0 0 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md index 50091888ad8..cb11ac30eb5 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -1,9 +1,11 @@ # MCP Server -This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. +This README was created using the C# MCP server project template. It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + ## Checklist before publishing to NuGet.org - Test the MCP server locally using the steps below. @@ -14,67 +16,70 @@ See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). -## Using the MCP Server in VS Code +## Developing locally -Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. ```json { - "mcp": { - "servers": { - "McpServer-CSharp": { - "type": "stdio", - "command": "dnx", - "args": [ - "", - "--version", - "", - "--yes" - ] - } + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] } } } ``` -Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org -## Developing locally in VS Code +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. -To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: ```json { "servers": { "McpServer-CSharp": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "run", - "--project", - "" + "", + "--version", + "", + "--yes" ] } } } ``` -Alternatively, you can configure your VS Code user settings to use your local project: +## More information -```json -{ - "mcp": { - "servers": { - "McpServer-CSharp": { - "type": "stdio", - "command": "dotnet", - "args": [ - "run", - "--project", - "" - ] - } - } - } -} -``` +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md index 5c00a3bf669..a0bf0fc082d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -1,9 +1,11 @@ # MCP Server -This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. +This README was created using the C# MCP server project template. It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + ## Checklist before publishing to NuGet.org - Test the MCP server locally using the steps below. @@ -14,67 +16,70 @@ See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). -## Using the MCP Server in VS Code +## Developing locally -Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. ```json { - "mcp": { - "servers": { - "mcpserver": { - "type": "stdio", - "command": "dnx", - "args": [ - "", - "--version", - "", - "--yes" - ] - } + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] } } } ``` -Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org -## Developing locally in VS Code +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. -To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: ```json { "servers": { "mcpserver": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "run", - "--project", - "" + "", + "--version", + "", + "--yes" ] } } } ``` -Alternatively, you can configure your VS Code user settings to use your local project: +## More information -```json -{ - "mcp": { - "servers": { - "mcpserver": { - "type": "stdio", - "command": "dotnet", - "args": [ - "run", - "--project", - "" - ] - } - } - } -} -``` +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) From eaf8a5d22838a52d4ede69022700b6f3f13056b7 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 15 Jul 2025 11:50:21 +0300 Subject: [PATCH 5/5] Fix schema generation for Nullable function parameters. (#6596) * Fix schema generation for Nullable function parameters. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs * Incorporate fix from https://github.com/dotnet/runtime/issues/117493. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs * Extend fix to include AllowReadingFromString. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AIJsonUtilities.Schema.Create.cs | 60 +++++++++++++--- .../JsonSchemaExporter.ReflectionHelpers.cs | 2 - .../JsonSchemaExporter/JsonSchemaExporter.cs | 16 +++-- .../AssertExtensions.cs | 24 ++++--- .../Utilities/AIJsonUtilitiesTests.cs | 16 +++-- .../Functions/AIFunctionFactoryTest.cs | 68 +++++++++++++++++++ test/Shared/JsonSchemaExporter/TestData.cs | 10 ++- test/Shared/JsonSchemaExporter/TestTypes.cs | 47 ++++++------- 8 files changed, 184 insertions(+), 59 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index a9d3ac3e3ee..c77e7dffb5b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -289,24 +289,49 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js objSchema.InsertAtStart(TypePropertyName, "string"); } - // Include the type keyword in nullable enum types - if (Nullable.GetUnderlyingType(ctx.TypeInfo.Type)?.IsEnum is true && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) - { - objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); - } - // Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand // schemas with "type": [...], and only understand "type" being a single value. // In certain configurations STJ represents .NET numeric types as ["string", "number"], which will then lead to an error. - if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType)) + if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType, out bool isNullable)) { // We don't want to emit any array for "type". In this case we know it contains "integer" or "number", // so reduce the type to that alone, assuming it's the most specific type. // This makes schemas for Int32 (etc) work with Ollama. JsonObject obj = ConvertSchemaToObject(ref schema); - obj[TypePropertyName] = numericType; + if (isNullable) + { + // If the type is nullable, we still need use a type array + obj[TypePropertyName] = new JsonArray { (JsonNode)numericType, (JsonNode)"null" }; + } + else + { + obj[TypePropertyName] = (JsonNode)numericType; + } + _ = obj.Remove(PatternPropertyName); } + + if (Nullable.GetUnderlyingType(ctx.TypeInfo.Type) is Type nullableElement) + { + // Account for bug https://github.com/dotnet/runtime/issues/117493 + // To be removed once System.Text.Json v10 becomes the lowest supported version. + // null not inserted in the type keyword for root-level Nullable types. + if (objSchema.TryGetPropertyValue(TypePropertyName, out JsonNode? typeKeyWord) && + typeKeyWord?.GetValueKind() is JsonValueKind.String) + { + string typeValue = typeKeyWord.GetValue()!; + if (typeValue is not "null") + { + objSchema[TypePropertyName] = new JsonArray { (JsonNode)typeValue, (JsonNode)"null" }; + } + } + + // Include the type keyword in nullable enum types + if (nullableElement.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + { + objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); + } + } } if (ctx.Path.IsEmpty && hasDefaultValue) @@ -601,11 +626,12 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali } } - private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType) + private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType, out bool isNullable) { numericType = null; + isNullable = false; - if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray { Count: 2 } typeArray) + if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray typeArray) { bool allowString = false; @@ -617,11 +643,23 @@ private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateCont switch (type) { case "integer" or "number": + if (numericType is not null) + { + // Conflicting numeric type + return false; + } + numericType = type; break; case "string": allowString = true; break; + case "null": + isNullable = true; + break; + default: + // keyword is not valid in the context of numeric types. + return false; } } } @@ -665,7 +703,7 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) if (defaultValue is null || (defaultValue == DBNull.Value && parameterType != typeof(DBNull))) { - return parameterType.IsValueType + return parameterType.IsValueType && Nullable.GetUnderlyingType(parameterType) is null #if NET ? RuntimeHelpers.GetUninitializedObject(parameterType) #else diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs index 481e5f75753..6d350dab026 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs @@ -31,8 +31,6 @@ private static class ReflectionHelpers public static bool IsBuiltInConverter(JsonConverter converter) => converter.GetType().Assembly == typeof(JsonConverter).Assembly; - public static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; - public static Type GetElementType(JsonTypeInfo typeInfo) { Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs index 2d8ffc5497c..d651ce6a727 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -452,20 +452,24 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) bool IsNullableSchema(ref GenerationState state) { - // A schema is marked as nullable if either + // A schema is marked as nullable if either: // 1. We have a schema for a property where either the getter or setter are marked as nullable. - // 2. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable + // 2. We have a schema for a Nullable type. + // 3. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable. if (propertyInfo != null || parameterInfo != null) { return !isNonNullableType; } - else + + if (Nullable.GetUnderlyingType(typeInfo.Type) is not null) { - return ReflectionHelpers.CanBeNull(typeInfo.Type) && - !parentPolymorphicTypeIsNonNullable && - !state.ExporterOptions.TreatNullObliviousAsNonNullable; + return true; } + + return !typeInfo.Type.IsValueType && + !parentPolymorphicTypeIsNonNullable && + !state.ExporterOptions.TreatNullObliviousAsNonNullable; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs index 72985108c6e..6361fe7817e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -53,21 +53,29 @@ public static void EqualFunctionCallParameters( public static void EqualFunctionCallResults(object? expected, object? actual, JsonSerializerOptions? options = null) => AreJsonEquivalentValues(expected, actual, options); - private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + /// + /// Asserts that the two JSON values are equal. + /// + public static void EqualJsonValues(JsonElement expectedJson, JsonElement actualJson, string? propertyName = null) { - options ??= AIJsonUtilities.DefaultOptions; - JsonElement expectedElement = NormalizeToElement(expected, options); - JsonElement actualElement = NormalizeToElement(actual, options); if (!JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(expectedElement, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(actualElement, AIJsonUtilities.DefaultOptions))) + JsonSerializer.SerializeToNode(expectedJson, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(actualJson, AIJsonUtilities.DefaultOptions))) { string message = propertyName is null - ? $"Function result does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}" - : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}"; + ? $"JSON result does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}" + : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}"; throw new XunitException(message); } + } + + private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + { + options ??= AIJsonUtilities.DefaultOptions; + JsonElement expectedElement = NormalizeToElement(expected, options); + JsonElement actualElement = NormalizeToElement(actual, options); + EqualJsonValues(expectedElement, actualElement, propertyName); static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options) => value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 19b2fc8bb48..c2177486fea 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -354,13 +354,21 @@ public static void CreateFunctionJsonSchema_TreatsIntegralTypesAsInteger_EvenWit int i = 0; foreach (JsonProperty property in schemaParameters.EnumerateObject()) { - string numericType = Type.GetTypeCode(parameters[i].ParameterType) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal - ? "number" - : "integer"; + bool isNullable = false; + Type type = parameters[i].ParameterType; + if (Nullable.GetUnderlyingType(type) is { } elementType) + { + type = elementType; + isNullable = true; + } + + string numericType = Type.GetTypeCode(type) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal + ? "\"number\"" + : "\"integer\""; JsonElement expected = JsonDocument.Parse($$""" { - "type": "{{numericType}}" + "type": {{(isNullable ? $"[{numericType}, \"null\"]" : numericType)}} } """).RootElement; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index afced22038f..59f04c40dfd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; @@ -838,6 +839,71 @@ public async Task AIFunctionFactory_DefaultDefaultParameter() Assert.Contains("00000000-0000-0000-0000-000000000000,0", result?.ToString()); } + [Fact] + public async Task AIFunctionFactory_NullableParameters() + { + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: JsonContext.Default.Options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + + [Fact] + public async Task AIFunctionFactory_NullableParameters_AllowReadingFromString() + { + JsonSerializerOptions options = new(JsonContext.Default.Options) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + [Fact] public void AIFunctionFactory_ReturnTypeWithDescriptionAttribute() { @@ -943,5 +1009,7 @@ private static AIFunctionFactoryOptions CreateKeyedServicesSupportOptions() => [JsonSerializable(typeof(Guid))] [JsonSerializable(typeof(StructWithDefaultCtor))] [JsonSerializable(typeof(B))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(DateTime?))] private partial class JsonContext : JsonSerializerContext; } diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs index 26902bfe0db..7c7cc7fc9a7 100644 --- a/test/Shared/JsonSchemaExporter/TestData.cs +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -13,7 +13,9 @@ internal sealed record TestData( T? Value, [StringSyntax(StringSyntaxAttribute.Json)] string ExpectedJsonSchema, IEnumerable? AdditionalValues = null, - object? ExporterOptions = null, +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + System.Text.Json.Schema.JsonSchemaExporterOptions? ExporterOptions = null, +#endif JsonSerializerOptions? Options = null, bool WritesNumbersAsStrings = false) : ITestData @@ -22,7 +24,9 @@ internal sealed record TestData( public Type Type => typeof(T); object? ITestData.Value => Value; +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ITestData.ExporterOptions => ExporterOptions; +#endif JsonNode ITestData.ExpectedJsonSchema { get; } = JsonNode.Parse(ExpectedJsonSchema, documentOptions: _schemaParseOptions) ?? throw new ArgumentNullException("schema must not be null"); @@ -32,7 +36,7 @@ IEnumerable ITestData.GetTestDataForAllValues() yield return this; if (default(T) is null && -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL ExporterOptions is System.Text.Json.Schema.JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable: false } && #endif Value is not null) @@ -58,7 +62,9 @@ public interface ITestData JsonNode ExpectedJsonSchema { get; } +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ExporterOptions { get; } +#endif JsonSerializerOptions? Options { get; } diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index 7cfd0ce45be..794e58fa2b8 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -9,12 +9,9 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; -#if NET9_0_OR_GREATER -using System.Reflection; -#endif using System.Text.Json; using System.Text.Json.Nodes; -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL using System.Text.Json.Schema; #endif using System.Text.Json.Serialization; @@ -135,6 +132,21 @@ public static IEnumerable GetTestDataCore() } """); +#if !NET9_0 && TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + // Regression test for https://github.com/dotnet/runtime/issues/117493 + yield return new TestData( + Value: 42, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["integer","null"]}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + + yield return new TestData( + Value: DateTimeOffset.MinValue, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["string","null"],"format":"date-time"}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); +#endif + // User-defined POCOs yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -152,7 +164,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with nullable types set to non-nullable yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -311,7 +323,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with non-nullable reference types by default. yield return new TestData( Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, @@ -761,7 +773,7 @@ of the type which points to the first occurrence. */ } """); -#if NET9_0_OR_GREATER +#if TEST yield return new TestData( Value: new("string", -1), ExpectedJsonSchema: """ @@ -1164,7 +1176,7 @@ public readonly struct StructDictionary(IEnumerable _dictionary.Count; public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); -#if NETCOREAPP +#if NET public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetValue(key, out value); #else public bool TryGetValue(TKey key, out TValue value) => _dictionary.TryGetValue(key, out value); @@ -1249,6 +1261,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(IntEnum?))] [JsonSerializable(typeof(StringEnum?))] [JsonSerializable(typeof(SimpleRecordStruct?))] + [JsonSerializable(typeof(DateTimeOffset?))] // User-defined POCOs [JsonSerializable(typeof(SimplePoco))] [JsonSerializable(typeof(SimpleRecord))] @@ -1299,22 +1312,4 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(StructDictionary))] [JsonSerializable(typeof(XElement))] public partial class TestTypesContext : JsonSerializerContext; - -#if NET9_0_OR_GREATER - private static TAttribute? ResolveAttribute(this JsonSchemaExporterContext ctx) - where TAttribute : Attribute - { - // Resolve attributes from locations in the following order: - // 1. Property-level attributes - // 2. Parameter-level attributes and - // 3. Type-level attributes. - return - GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? - GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? - GetAttrs(ctx.TypeInfo.Type); - - static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => - (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); - } -#endif }