Fix ToJsonObject serialization failure with anonymous types and add support for custom JsonSerializerOptions (#1113)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
Copilot 2026-01-20 11:21:11 +02:00 • committed by GitHub
commit 46d30444a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 366 additions and 42 deletions

View file

@ -23,6 +23,7 @@ public static class AIContentExtensions
/// satisfy sampling requests using the specified <see cref="IChatClient"/>.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> with which to satisfy sampling requests.</param>
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use for serializing user-provided objects. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <returns>The created handler delegate that can be assigned to <see cref="McpClientHandlers.SamplingHandler"/>.</returns>
/// <remarks>
/// <para>
@ -36,15 +37,18 @@ public static class AIContentExtensions
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="chatClient"/> is <see langword="null"/>.</exception>
public static Func<CreateMessageRequestParams?, IProgress<ProgressNotificationValue>, CancellationToken, ValueTask<CreateMessageResult>> CreateSamplingHandler(
this IChatClient chatClient)
this IChatClient chatClient,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(chatClient);
serializerOptions ??= McpJsonUtilities.DefaultOptions;
return async (requestParams, progress, cancellationToken) =>
{
Throw.IfNull(requestParams);
var (messages, options) = ToChatClientArguments(requestParams);
var (messages, options) = ToChatClientArguments(requestParams, serializerOptions);
var progressToken = requestParams.ProgressToken;
List<ChatResponseUpdate> updates = [];
@ -75,12 +79,12 @@ 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,
};
static (IList<ChatMessage> Messages, ChatOptions? Options) ToChatClientArguments(CreateMessageRequestParams requestParams)
static (IList<ChatMessage> Messages, ChatOptions? Options) ToChatClientArguments(CreateMessageRequestParams requestParams, JsonSerializerOptions serializerOptions)
{
ChatOptions? options = null;
@ -126,7 +130,7 @@ public static class AIContentExtensions
List<ChatMessage> messages = [];
foreach (var sm in requestParams.Messages)
{
if (sm.Content?.Select(b => b.ToAIContent()).OfType<AIContent>().ToList() is { Count: > 0 } aiContents)
if (sm.Content?.Select(b => b.ToAIContent(serializerOptions)).OfType<AIContent>().ToList() is { Count: > 0 } aiContents)
{
ChatRole role =
aiContents.All(static c => c is FunctionResultContent) ? ChatRole.Tool :
@ -142,8 +146,10 @@ public static class AIContentExtensions
}
/// <summary>Converts the specified dictionary to a <see cref="JsonObject"/>.</summary>
internal static JsonObject? ToJsonObject(this IReadOnlyDictionary<string, object?> properties) =>
JsonSerializer.SerializeToNode(properties, McpJsonUtilities.JsonContext.Default.IReadOnlyDictionaryStringObject) as JsonObject;
internal static JsonObject? ToJsonObject(this IReadOnlyDictionary<string, object?> properties, JsonSerializerOptions options)
{
return JsonSerializer.SerializeToNode(properties, options.GetTypeInfo(typeof(IReadOnlyDictionary<string, object?>))) as JsonObject;
}
internal static AdditionalPropertiesDictionary ToAdditionalProperties(this JsonObject obj)
{
@ -160,17 +166,18 @@ public static class AIContentExtensions
/// Converts a <see cref="PromptMessage"/> to a <see cref="ChatMessage"/> object.
/// </summary>
/// <param name="promptMessage">The prompt message to convert.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> to use for deserialization. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <returns>A <see cref="ChatMessage"/> object created from the prompt message.</returns>
/// <remarks>
/// This method transforms a protocol-specific <see cref="PromptMessage"/> from the Model Context Protocol
/// into a standard <see cref="ChatMessage"/> object that can be used with AI client libraries.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="promptMessage"/> is <see langword="null"/>.</exception>
public static ChatMessage ToChatMessage(this PromptMessage promptMessage)
public static ChatMessage ToChatMessage(this PromptMessage promptMessage, JsonSerializerOptions? options = null)
{
Throw.IfNull(promptMessage);
AIContent? content = ToAIContent(promptMessage.Content);
AIContent? content = promptMessage.Content.ToAIContent(options);
return new()
{
@ -185,6 +192,7 @@ public static class AIContentExtensions
/// </summary>
/// <param name="result">The tool result to convert.</param>
/// <param name="callId">The identifier for the function call request that triggered the tool invocation.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> to use for serialization. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <returns>A <see cref="ChatMessage"/> object created from the tool result.</returns>
/// <remarks>
/// This method transforms a protocol-specific <see cref="CallToolResult"/> from the Model Context Protocol
@ -193,12 +201,14 @@ public static class AIContentExtensions
/// serialized <see cref="JsonElement"/>.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="result"/> or <paramref name="callId"/> is <see langword="null"/>.</exception>
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<CallToolResult>()))
{
RawRepresentation = result,
}]);
@ -252,6 +262,7 @@ public static class AIContentExtensions
/// <summary>Creates a new <see cref="AIContent"/> from the content of a <see cref="ContentBlock"/>.</summary>
/// <param name="content">The <see cref="ContentBlock"/> to convert.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> to use for deserialization. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <returns>
/// The created <see cref="AIContent"/>. If the content can't be converted (such as when it's a resource link), <see langword="null"/> is returned.
/// </returns>
@ -260,10 +271,12 @@ public static class AIContentExtensions
/// content types, enabling seamless integration between the protocol and AI client libraries.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="content"/> is <see langword="null"/>.</exception>
public static AIContent? ToAIContent(this ContentBlock content)
public static AIContent? ToAIContent(this ContentBlock content, JsonSerializerOptions? options = null)
{
Throw.IfNull(content);
options ??= McpJsonUtilities.DefaultOptions;
AIContent? ac = content switch
{
TextContentBlock textContent => new TextContent(textContent.Text),
@ -275,11 +288,11 @@ public static class AIContentExtensions
EmbeddedResourceBlock resourceContent => resourceContent.Resource.ToAIContent(),
ToolUseContentBlock toolUse => FunctionCallContent.CreateFromParsedArguments(toolUse.Input, toolUse.Id, toolUse.Name,
static json => JsonSerializer.Deserialize(json, McpJsonUtilities.JsonContext.Default.IDictionaryStringObject)),
json => JsonSerializer.Deserialize(json, options.GetTypeInfo<IDictionary<string, object?>>())),
ToolResultContentBlock toolResult => new FunctionResultContent(
toolResult.ToolUseId,
toolResult.Content.Count == 1 ? toolResult.Content[0].ToAIContent() : toolResult.Content.Select(c => c.ToAIContent()).OfType<AIContent>().ToList())
toolResult.Content.Count == 1 ? toolResult.Content[0].ToAIContent(options) : toolResult.Content.Select(c => c.ToAIContent(options)).OfType<AIContent>().ToList())
{
Exception = toolResult.IsError is true ? new() : null,
},
@ -324,6 +337,7 @@ public static class AIContentExtensions
/// <summary>Creates a list of <see cref="AIContent"/> from a sequence of <see cref="ContentBlock"/>.</summary>
/// <param name="contents">The <see cref="ContentBlock"/> instances to convert.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> to use for deserialization. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <returns>The created <see cref="AIContent"/> instances.</returns>
/// <remarks>
/// <para>
@ -332,16 +346,16 @@ public static class AIContentExtensions
/// when processing the contents of a message or response.
/// </para>
/// <para>
/// Each <see cref="ContentBlock"/> object is converted using <see cref="ToAIContent(ContentBlock)"/>,
/// Each <see cref="ContentBlock"/> object is converted using <see cref="ToAIContent(ContentBlock, JsonSerializerOptions?)"/>,
/// preserving the type-specific conversion logic for text, images, audio, and resources.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="contents"/> is <see langword="null"/>.</exception>
public static IList<AIContent> ToAIContents(this IEnumerable<ContentBlock> contents)
public static IList<AIContent> ToAIContents(this IEnumerable<ContentBlock> contents, JsonSerializerOptions? options = null)
{
Throw.IfNull(contents);
return [.. contents.Select(ToAIContent).OfType<AIContent>()];
return [.. contents.Select(c => c.ToAIContent(options)).OfType<AIContent>()];
}
/// <summary>Creates a list of <see cref="AIContent"/> from a sequence of <see cref="ResourceContents"/>.</summary>
@ -369,12 +383,15 @@ public static class AIContentExtensions
/// <summary>Creates a new <see cref="ContentBlock"/> from the content of an <see cref="AIContent"/>.</summary>
/// <param name="content">The <see cref="AIContent"/> to convert.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> to use for serialization. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <returns>The created <see cref="ContentBlock"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="content"/> is <see langword="null"/>.</exception>
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
@ -408,7 +425,7 @@ public static class AIContentExtensions
{
Id = callContent.CallId,
Name = callContent.Name,
Input = JsonSerializer.SerializeToElement(callContent.Arguments, McpJsonUtilities.DefaultOptions.GetTypeInfo<IDictionary<string, object?>>()!),
Input = JsonSerializer.SerializeToElement(callContent.Arguments, options.GetTypeInfo<IDictionary<string, object?>>()!),
},
FunctionResultContent resultContent => new ToolResultContentBlock()
@ -416,19 +433,19 @@ public static class AIContentExtensions
ToolUseId = resultContent.CallId,
IsError = resultContent.Exception is not null,
Content =
resultContent.Result is AIContent c ? [c.ToContentBlock()] :
resultContent.Result is IEnumerable<AIContent> ec ? [.. ec.Select(c => c.ToContentBlock())] :
[new TextContentBlock { Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo<object>()) }],
resultContent.Result is AIContent c ? [c.ToContentBlock(options)] :
resultContent.Result is IEnumerable<AIContent> ec ? [.. ec.Select(c => c.ToContentBlock(options))] :
[new TextContentBlock { Text = JsonSerializer.Serialize(content, options.GetTypeInfo<object>()) }],
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;
}

View file

@ -139,10 +139,10 @@ public sealed class McpClientTool : AIFunction
{
switch (result.Content.Count)
{
case 1 when result.Content[0].ToAIContent() is { } aiContent:
case 1 when result.Content[0].ToAIContent(JsonSerializerOptions) is { } aiContent:
return aiContent;
case > 1 when result.Content.Select(c => c.ToAIContent()).ToArray() is { } aiContents && aiContents.All(static c => c is not null):
case > 1 when result.Content.Select(c => c.ToAIContent(JsonSerializerOptions)).ToArray() is { } aiContents && aiContents.All(static c => c is not null):
return aiContents;
}
}

View file

@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol;
/// <para>
/// <see cref="PromptMessage"/> objects are typically used in collections within <see cref="GetPromptResult"/>
/// to represent complete conversations or prompt sequences. They can be converted to and from <see cref="ChatMessage"/>
/// objects using the extension methods <see cref="AIContentExtensions.ToChatMessage(PromptMessage)"/> and
/// objects using the extension methods <see cref="AIContentExtensions.ToChatMessage(PromptMessage, System.Text.Json.JsonSerializerOptions?)"/> and
/// <see cref="AIContentExtensions.ToPromptMessages(ChatMessage)"/>.
/// </para>
/// <para>

View file

@ -73,16 +73,19 @@ public abstract partial class McpServer : McpSession
/// </summary>
/// <param name="messages">The messages to send as part of the request.</param>
/// <param name="chatOptions">The options to use for the request, including model parameters and constraints.</param>
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use for serializing user-provided objects. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task containing the chat response from the model.</returns>
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
public async Task<ChatResponse> SampleAsync(
IEnumerable<ChatMessage> messages, ChatOptions? chatOptions = default, CancellationToken cancellationToken = default)
IEnumerable<ChatMessage> 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 abstract partial class McpServer : McpSession
Name = af.Name,
Description = af.Description,
InputSchema = af.JsonSchema,
Meta = af.AdditionalProperties.ToJsonObject(),
Meta = af.AdditionalProperties.ToJsonObject(serializerOptions),
});
}
}
@ -172,13 +175,13 @@ public abstract partial class McpServer : McpSession
Temperature = chatOptions?.Temperature,
ToolChoice = toolChoice,
Tools = tools,
Meta = chatOptions?.AdditionalProperties?.ToJsonObject(),
Meta = chatOptions?.AdditionalProperties?.ToJsonObject(serializerOptions),
}, cancellationToken).ConfigureAwait(false);
List<AIContent> responseContents = [];
foreach (var block in result.Content)
{
if (block.ToAIContent() is { } content)
if (block.ToAIContent(serializerOptions) is { } content)
{
responseContents.Add(content);
}
@ -202,13 +205,14 @@ public abstract partial class McpServer : McpSession
/// <summary>
/// Creates an <see cref="IChatClient"/> wrapper that can be used to send sampling requests to the client.
/// </summary>
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use for serialization. If <see langword="null"/>, <see cref="McpJsonUtilities.DefaultOptions"/> is used.</param>
/// <returns>The <see cref="IChatClient"/> that can be used to issue sampling requests to the client.</returns>
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
public IChatClient AsSamplingChatClient()
public IChatClient AsSamplingChatClient(JsonSerializerOptions? serializerOptions = null)
{
ThrowIfSamplingUnsupported();
return new SamplingChatClient(this);
return new SamplingChatClient(this, serializerOptions ?? McpJsonUtilities.DefaultOptions);
}
/// <summary>Gets an <see cref="ILogger"/> on which logged messages will be sent as notifications to the client.</summary>
@ -520,13 +524,14 @@ public abstract partial class McpServer : McpSession
}
/// <summary>Provides an <see cref="IChatClient"/> implementation that's implemented via client sampling.</summary>
private sealed class SamplingChatClient(McpServer server) : IChatClient
private sealed class SamplingChatClient(McpServer server, JsonSerializerOptions serializerOptions) : IChatClient
{
private readonly McpServer _server = server;
private readonly JsonSerializerOptions _serializerOptions = serializerOptions;
/// <inheritdoc/>
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? chatOptions = null, CancellationToken cancellationToken = default) =>
_server.SampleAsync(messages, chatOptions, cancellationToken);
_server.SampleAsync(messages, chatOptions, _serializerOptions, cancellationToken);
/// <inheritdoc/>
async IAsyncEnumerable<ChatResponseUpdate> IChatClient.GetStreamingResponseAsync(

View file

@ -160,7 +160,7 @@ public sealed class McpServerOptions
/// The default maximum number of tokens to use for sampling requests. The default value is 1000 tokens.
/// </value>
/// <remarks>
/// This value is used in <see cref="McpServer.SampleAsync(IEnumerable{Microsoft.Extensions.AI.ChatMessage}, Microsoft.Extensions.AI.ChatOptions?, CancellationToken)"/>
/// This value is used in <see cref="McpServer.SampleAsync(IEnumerable{Microsoft.Extensions.AI.ChatMessage}, Microsoft.Extensions.AI.ChatOptions?, System.Text.Json.JsonSerializerOptions?, CancellationToken)"/>
/// when <see cref="Microsoft.Extensions.AI.ChatOptions.MaxOutputTokens"/> is not set in the request options.
/// </remarks>
public int MaxSamplingOutputTokens { get; set; } = 1000;

View file

@ -99,7 +99,7 @@ namespace ModelContextProtocol.Server;
/// </item>
/// <item>
/// <term><see cref="AIContent"/></term>
/// <description>Converted to a single <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent)"/>.</description>
/// <description>Converted to a single <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent, JsonSerializerOptions)"/>.</description>
/// </item>
/// <item>
/// <term><see cref="string"/></term>
@ -111,7 +111,7 @@ namespace ModelContextProtocol.Server;
/// </item>
/// <item>
/// <term><see cref="IEnumerable{AIContent}"/> of <see cref="AIContent"/></term>
/// <description>Each <see cref="AIContent"/> is converted to a <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent)"/>.</description>
/// <description>Each <see cref="AIContent"/> is converted to a <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent, JsonSerializerOptions)"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{ContentBlock}"/> of <see cref="ContentBlock"/></term>

View file

@ -90,7 +90,7 @@ namespace ModelContextProtocol.Server;
/// </item>
/// <item>
/// <term><see cref="AIContent"/></term>
/// <description>Converted to a single <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent)"/>.</description>
/// <description>Converted to a single <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent, JsonSerializerOptions)"/>.</description>
/// </item>
/// <item>
/// <term><see cref="string"/></term>
@ -106,7 +106,7 @@ namespace ModelContextProtocol.Server;
/// </item>
/// <item>
/// <term><see cref="IEnumerable{AIContent}"/> of <see cref="AIContent"/></term>
/// <description>Each <see cref="AIContent"/> is converted to a <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent)"/>.</description>
/// <description>Each <see cref="AIContent"/> is converted to a <see cref="ContentBlock"/> object using <see cref="AIContentExtensions.ToContentBlock(AIContent, JsonSerializerOptions)"/>.</description>
/// </item>
/// <item>
/// <term><see cref="IEnumerable{Content}"/> of <see cref="ContentBlock"/></term>

View file

@ -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 class AIContentExtensionsTests
Assert.False(functionResult.Exception != null);
Assert.NotNull(functionResult.Result);
}
}
// 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<TextContentBlock>(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<ImageContentBlock>(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<ContentBlock>
{
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<string, object>))]
internal partial class NamedTypeTestJsonContext : JsonSerializerContext;

View file

@ -165,6 +165,14 @@ public class McpClientToolTests : ClientServerTestBase
var metaJson = meta?.ToJsonString() ?? "{}";
return new TextContentBlock { Text = metaJson };
}
// Tool that accepts arbitrary JsonElement parameter to test anonymous type serialization
[McpServerTool]
public static TextContentBlock ArgumentEchoTool(string text, JsonElement coordinates)
{
var result = new { text, coordinates };
return new TextContentBlock { Text = JsonSerializer.Serialize(result) };
}
}
[Fact]
@ -818,4 +826,32 @@ public class McpClientToolTests : ClientServerTestBase
Assert.NotNull(receivedMetadata);
Assert.Equal("requestOnlyValue", receivedMetadata["requestOnlyKey"]?.GetValue<string>());
}
[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<string, object?>
{
["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<TextContentBlock>(result.Content[0]);
Assert.Contains("coordinates", textBlock.Text);
}
}

View file

@ -809,7 +809,7 @@ public class McpServerTests : LoggedTest
{
await using var server = new TestServerForIChatClient(supportsSampling: false);
Assert.Throws<InvalidOperationException>(server.AsSamplingChatClient);
Assert.Throws<InvalidOperationException>(() => server.AsSamplingChatClient());
}
[Fact]