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:
parent
0585c27d53
commit
46d30444a8
3 changed files with 366 additions and 42 deletions
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue