diff --git a/src/ModelContextProtocol.Core/AIContentExtensions.cs b/src/ModelContextProtocol.Core/AIContentExtensions.cs index b1ba32bf..b07d36f7 100644 --- a/src/ModelContextProtocol.Core/AIContentExtensions.cs +++ b/src/ModelContextProtocol.Core/AIContentExtensions.cs @@ -23,6 +23,7 @@ public static class AIContentExtensions /// satisfy sampling requests using the specified . /// /// The with which to satisfy sampling requests. + /// The to use for serializing user-provided objects. If , is used. /// The created handler delegate that can be assigned to . /// /// @@ -36,10 +37,13 @@ public static class AIContentExtensions /// /// is . public static Func, CancellationToken, ValueTask> CreateSamplingHandler( - this IChatClient chatClient) + this IChatClient chatClient, + JsonSerializerOptions? serializerOptions = null) { Throw.IfNull(chatClient); + serializerOptions ??= McpJsonUtilities.DefaultOptions; + return async (requestParams, progress, cancellationToken) => { Throw.IfNull(requestParams); @@ -75,7 +79,7 @@ public static class AIContentExtensions chatResponse.FinishReason == ChatFinishReason.Length ? CreateMessageResult.StopReasonMaxTokens : chatResponse.FinishReason == ChatFinishReason.ToolCalls ? CreateMessageResult.StopReasonToolUse : chatResponse.FinishReason.ToString(), - Meta = chatResponse.AdditionalProperties?.ToJsonObject(), + Meta = chatResponse.AdditionalProperties?.ToJsonObject(serializerOptions), Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant, Content = contents, }; @@ -138,8 +142,10 @@ public static class AIContentExtensions } /// Converts the specified dictionary to a . - internal static JsonObject? ToJsonObject(this IReadOnlyDictionary properties) => - JsonSerializer.SerializeToNode(properties, McpJsonUtilities.JsonContext.Default.IReadOnlyDictionaryStringObject) as JsonObject; + internal static JsonObject? ToJsonObject(this IReadOnlyDictionary properties, JsonSerializerOptions options) + { + return JsonSerializer.SerializeToNode(properties, options.GetTypeInfo(typeof(IReadOnlyDictionary))) as JsonObject; + } internal static AdditionalPropertiesDictionary ToAdditionalProperties(this JsonObject obj) { @@ -181,6 +187,7 @@ public static ChatMessage ToChatMessage(this PromptMessage promptMessage) /// /// The tool result to convert. /// The identifier for the function call request that triggered the tool invocation. + /// The to use for serialization. If , is used. /// A object created from the tool result. /// /// This method transforms a protocol-specific from the Model Context Protocol @@ -189,12 +196,14 @@ public static ChatMessage ToChatMessage(this PromptMessage promptMessage) /// serialized . /// /// or is . - public static ChatMessage ToChatMessage(this CallToolResult result, string callId) + public static ChatMessage ToChatMessage(this CallToolResult result, string callId, JsonSerializerOptions? options = null) { Throw.IfNull(result); Throw.IfNull(callId); - return new(ChatRole.Tool, [new FunctionResultContent(callId, JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult)) + options ??= McpJsonUtilities.DefaultOptions; + + return new(ChatRole.Tool, [new FunctionResultContent(callId, JsonSerializer.SerializeToElement(result, options.GetTypeInfo())) { RawRepresentation = result, }]); @@ -271,7 +280,7 @@ public static IList ToPromptMessages(this ChatMessage chatMessage EmbeddedResourceBlock resourceContent => resourceContent.Resource.ToAIContent(), ToolUseContentBlock toolUse => FunctionCallContent.CreateFromParsedArguments(toolUse.Input, toolUse.Id, toolUse.Name, - static json => JsonSerializer.Deserialize(json, McpJsonUtilities.JsonContext.Default.IDictionaryStringObject)), + static json => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions.GetTypeInfo>())), ToolResultContentBlock toolResult => new FunctionResultContent( toolResult.ToolUseId, @@ -365,12 +374,15 @@ public static IList ToAIContents(this IEnumerable c /// Creates a new from the content of an . /// The to convert. + /// The to use for serialization. If , is used. /// The created . /// is . - public static ContentBlock ToContentBlock(this AIContent content) + public static ContentBlock ToContentBlock(this AIContent content, JsonSerializerOptions? options = null) { Throw.IfNull(content); + options ??= McpJsonUtilities.DefaultOptions; + ContentBlock contentBlock = content switch { TextContent textContent => new TextContentBlock @@ -404,7 +416,7 @@ public static ContentBlock ToContentBlock(this AIContent content) { Id = callContent.CallId, Name = callContent.Name, - Input = JsonSerializer.SerializeToElement(callContent.Arguments, McpJsonUtilities.DefaultOptions.GetTypeInfo>()!), + Input = JsonSerializer.SerializeToElement(callContent.Arguments, options.GetTypeInfo>()!), }, FunctionResultContent resultContent => new ToolResultContentBlock() @@ -412,19 +424,19 @@ public static ContentBlock ToContentBlock(this AIContent content) ToolUseId = resultContent.CallId, IsError = resultContent.Exception is not null, Content = - resultContent.Result is AIContent c ? [c.ToContentBlock()] : - resultContent.Result is IEnumerable ec ? [.. ec.Select(c => c.ToContentBlock())] : - [new TextContentBlock { Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo()) }], + resultContent.Result is AIContent c ? [c.ToContentBlock(options)] : + resultContent.Result is IEnumerable ec ? [.. ec.Select(c => c.ToContentBlock(options))] : + [new TextContentBlock { Text = JsonSerializer.Serialize(content, options.GetTypeInfo()) }], StructuredContent = resultContent.Result is JsonElement je ? je : null, }, _ => new TextContentBlock { - Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))), + Text = JsonSerializer.Serialize(content, options.GetTypeInfo(typeof(object))), } }; - contentBlock.Meta = content.AdditionalProperties?.ToJsonObject(); + contentBlock.Meta = content.AdditionalProperties?.ToJsonObject(options); return contentBlock; } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 7caabf68..83d2a6d2 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -73,16 +73,19 @@ public ValueTask SampleAsync( /// /// The messages to send as part of the request. /// The options to use for the request, including model parameters and constraints. + /// The to use for serializing user-provided objects. If , is used. /// The to monitor for cancellation requests. The default is . /// A task containing the chat response from the model. /// is . /// The client does not support sampling. /// The request failed or the client returned an error response. public async Task SampleAsync( - IEnumerable messages, ChatOptions? chatOptions = default, CancellationToken cancellationToken = default) + IEnumerable messages, ChatOptions? chatOptions = default, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) { Throw.IfNull(messages); + serializerOptions ??= McpJsonUtilities.DefaultOptions; + StringBuilder? systemPrompt = null; if (chatOptions?.Instructions is { } instructions) @@ -148,7 +151,7 @@ public async Task SampleAsync( Name = af.Name, Description = af.Description, InputSchema = af.JsonSchema, - Meta = af.AdditionalProperties.ToJsonObject(), + Meta = af.AdditionalProperties.ToJsonObject(serializerOptions), }); } } @@ -172,7 +175,7 @@ public async Task SampleAsync( Temperature = chatOptions?.Temperature, ToolChoice = toolChoice, Tools = tools, - Meta = chatOptions?.AdditionalProperties?.ToJsonObject(), + Meta = chatOptions?.AdditionalProperties?.ToJsonObject(serializerOptions), }, cancellationToken).ConfigureAwait(false); List responseContents = []; @@ -526,7 +529,7 @@ private sealed class SamplingChatClient(McpServer server) : IChatClient /// public Task GetResponseAsync(IEnumerable messages, ChatOptions? chatOptions = null, CancellationToken cancellationToken = default) => - _server.SampleAsync(messages, chatOptions, cancellationToken); + _server.SampleAsync(messages, chatOptions, serializerOptions: null, cancellationToken); /// async IAsyncEnumerable IChatClient.GetStreamingResponseAsync( diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 937e288c..4c7f7358 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -160,7 +160,7 @@ public McpServerHandlers Handlers /// The default maximum number of tokens to use for sampling requests. The default value is 1000 tokens. /// /// - /// This value is used in + /// This value is used in /// when is not set in the request options. /// public int MaxSamplingOutputTokens { get; set; } = 1000; diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index cebf7209..f2e75387 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -99,7 +99,7 @@ namespace ModelContextProtocol.Server; /// /// /// -/// Converted to a single object using . +/// Converted to a single object using . /// /// /// @@ -111,7 +111,7 @@ namespace ModelContextProtocol.Server; /// /// /// of -/// Each is converted to a object using . +/// Each is converted to a object using . /// /// /// of diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 834e3e4a..11c5a780 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -90,7 +90,7 @@ namespace ModelContextProtocol.Server; /// /// /// -/// Converted to a single object using . +/// Converted to a single object using . /// /// /// @@ -106,7 +106,7 @@ namespace ModelContextProtocol.Server; /// /// /// of -/// Each is converted to a object using . +/// Each is converted to a object using . /// /// /// of diff --git a/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs b/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs index 3a57a07c..7d4d797a 100644 --- a/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs +++ b/tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.AI; using ModelContextProtocol.Protocol; using System.Text.Json; +using System.Text.Json.Serialization; namespace ModelContextProtocol.Tests; @@ -148,4 +149,269 @@ public void ToAIContent_ToolResultToFunctionResultRoundTrip() Assert.False(functionResult.Exception != null); Assert.NotNull(functionResult.Result); } -} \ No newline at end of file + + // Tests for anonymous types in AdditionalProperties (sampling pipeline regression fix) + // These tests require reflection-based serialization and will be skipped when reflection is disabled. + + [Fact] + public void ToContentBlock_WithAnonymousTypeInAdditionalProperties_DoesNotThrow() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + // This is the minimal repro from the issue + AIContent c = new() + { + AdditionalProperties = new() + { + ["data"] = new { X = 1.0, Y = 2.0 } + } + }; + + // Should not throw NotSupportedException + var contentBlock = c.ToContentBlock(); + + Assert.NotNull(contentBlock); + Assert.NotNull(contentBlock.Meta); + Assert.True(contentBlock.Meta.ContainsKey("data")); + } + + [Fact] + public void ToContentBlock_WithMultipleAnonymousTypes_DoesNotThrow() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + AIContent c = new() + { + AdditionalProperties = new() + { + ["point"] = new { X = 1.0, Y = 2.0 }, + ["metadata"] = new { Name = "Test", Id = 42 }, + ["config"] = new { Enabled = true, Timeout = 30 } + } + }; + + var contentBlock = c.ToContentBlock(); + + Assert.NotNull(contentBlock); + Assert.NotNull(contentBlock.Meta); + Assert.Equal(3, contentBlock.Meta.Count); + } + + [Fact] + public void ToContentBlock_WithNestedAnonymousTypes_DoesNotThrow() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + AIContent c = new() + { + AdditionalProperties = new() + { + ["outer"] = new + { + Inner = new { Value = "test" }, + Count = 5 + } + } + }; + + var contentBlock = c.ToContentBlock(); + + Assert.NotNull(contentBlock); + Assert.NotNull(contentBlock.Meta); + Assert.True(contentBlock.Meta.ContainsKey("outer")); + } + + [Fact] + public void ToContentBlock_WithMixedTypesInAdditionalProperties_DoesNotThrow() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + AIContent c = new() + { + AdditionalProperties = new() + { + ["anonymous"] = new { X = 1.0, Y = 2.0 }, + ["string"] = "test", + ["number"] = 42, + ["boolean"] = true, + ["array"] = new[] { 1, 2, 3 } + } + }; + + var contentBlock = c.ToContentBlock(); + + Assert.NotNull(contentBlock); + Assert.NotNull(contentBlock.Meta); + Assert.Equal(5, contentBlock.Meta.Count); + } + + [Fact] + public void TextContent_ToContentBlock_WithAnonymousTypeInAdditionalProperties_PreservesData() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + TextContent textContent = new("Hello, world!") + { + AdditionalProperties = new() + { + ["location"] = new { Lat = 40.7128, Lon = -74.0060 } + } + }; + + var contentBlock = textContent.ToContentBlock(); + var textBlock = Assert.IsType(contentBlock); + + Assert.Equal("Hello, world!", textBlock.Text); + Assert.NotNull(textBlock.Meta); + Assert.True(textBlock.Meta.ContainsKey("location")); + } + + [Fact] + public void DataContent_ToContentBlock_WithAnonymousTypeInAdditionalProperties_PreservesData() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + byte[] imageData = [1, 2, 3, 4, 5]; + DataContent dataContent = new(imageData, "image/png") + { + AdditionalProperties = new() + { + ["dimensions"] = new { Width = 100, Height = 200 } + } + }; + + var contentBlock = dataContent.ToContentBlock(); + var imageBlock = Assert.IsType(contentBlock); + + Assert.Equal(Convert.ToBase64String(imageData), imageBlock.Data); + Assert.Equal("image/png", imageBlock.MimeType); + Assert.NotNull(imageBlock.Meta); + Assert.True(imageBlock.Meta.ContainsKey("dimensions")); + } + + [Fact] + public void ToContentBlock_WithCustomSerializerOptions_UsesProvidedOptions() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + // Create custom options with specific settings + var customOptions = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + AIContent c = new() + { + AdditionalProperties = new() + { + ["TestData"] = new { MyProperty = "value" } + } + }; + + var contentBlock = c.ToContentBlock(customOptions); + + Assert.NotNull(contentBlock); + Assert.NotNull(contentBlock.Meta); + + // Verify that the custom naming policy was applied + var json = contentBlock.Meta.ToString(); + Assert.Contains("my_property", json.ToLowerInvariant()); + } + + [Fact] + public void ToContentBlock_WithNamedUserDefinedTypeInAdditionalProperties_Works() + { + // This test should work regardless of reflection being enabled/disabled + // because named types can be handled by source generators + + // Create options with source generation support for the test type + var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions); + options.TypeInfoResolverChain.Add(NamedTypeTestJsonContext.Default); + + // Define a simple named type + var testData = new TestCoordinates { X = 1.0, Y = 2.0 }; + + AIContent c = new() + { + AdditionalProperties = new() + { + ["coordinates"] = testData + } + }; + + // Should not throw NotSupportedException + var contentBlock = c.ToContentBlock(options); + + Assert.NotNull(contentBlock); + Assert.NotNull(contentBlock.Meta); + Assert.True(contentBlock.Meta.ContainsKey("coordinates")); + + // Verify the data was serialized correctly + var coordinatesNode = contentBlock.Meta["coordinates"]; + Assert.NotNull(coordinatesNode); + + var json = coordinatesNode.ToString(); + Assert.Contains("1", json); + Assert.Contains("2", json); + } + + [Fact] + public void ToChatMessage_CallToolResult_WithAnonymousTypeInContent_Works() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + // Create a CallToolResult with anonymous type data in the content + var result = new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Text = "Result with metadata", + Meta = JsonSerializer.SerializeToNode(new { Status = "success", Code = 200 }) as System.Text.Json.Nodes.JsonObject + } + } + }; + + // This should not throw NotSupportedException + var exception = Record.Exception(() => result.ToChatMessage("call_123")); + + Assert.Null(exception); + } +} + +// Test type for named user-defined type test +internal record TestCoordinates +{ + public double X { get; init; } + public double Y { get; init; } +} + +// Source generation context for the test type +[JsonSerializable(typeof(TestCoordinates))] +[JsonSerializable(typeof(IReadOnlyDictionary))] +internal partial class NamedTypeTestJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index c1b2bcf2..83d3aaec 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -165,6 +165,14 @@ public static TextContentBlock MetadataEchoTool(RequestContext()); } + + [Fact] + public async Task CallToolAsync_WithAnonymousTypeArguments_Works() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + return; + } + + await using McpClient client = await CreateMcpClientForServer(); + + // Call with dictionary containing anonymous type values + var arguments = new Dictionary + { + ["text"] = "test", + ["coordinates"] = new { X = 1.0, Y = 2.0 } // Anonymous type + }; + + // This should not throw NotSupportedException + var result = await client.CallToolAsync("argument_echo_tool", arguments, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotEmpty(result.Content); + + // Verify the anonymous type was serialized correctly + var textBlock = Assert.IsType(result.Content[0]); + Assert.Contains("coordinates", textBlock.Text); + } }