Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/ModelContextProtocol.Core/AIContentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ 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;
JsonSerializer.SerializeToNode(properties, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IReadOnlyDictionary<string, object?>))) as JsonObject;

internal static AdditionalPropertiesDictionary ToAdditionalProperties(this JsonObject obj)
{
Expand Down Expand Up @@ -271,7 +271,7 @@ public static IList<PromptMessage> 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<IDictionary<string, object?>>())),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, ToAIContent needs to accept an optional JSO, and all callers in the library should be passing in a user supplied instance

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added optional JsonSerializerOptions parameter to ToAIContent methods and updated all callers to pass user-supplied options in commit 46425b2.


ToolResultContentBlock toolResult => new FunctionResultContent(
toolResult.ToolUseId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using System.Text.Json;

namespace ModelContextProtocol.Tests;

/// <summary>
/// Tests for AIContentExtensions with anonymous types in AdditionalProperties.
/// This validates the fix for the sampling pipeline regression in 0.5.0-preview.1.
/// These tests require reflection-based serialization and will be skipped when reflection is disabled.
/// </summary>
public class AIContentExtensionsAnonymousTypeTests
{
[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"));
}
}
38 changes: 38 additions & 0 deletions tests/ModelContextProtocol.Tests/RegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.Extensions.AI;
using System.Text.Json;

namespace ModelContextProtocol.Tests;

/// <summary>
/// Regression tests for specific issues that were reported and fixed.
/// </summary>
public class RegressionTests
{
/// <summary>
/// Regression test for GitHub issue: ToJsonObject fails when dictionary values contain anonymous types.
/// This is a sampling pipeline regression from version 0.5.0-preview.1.
/// </summary>
[Fact]
public void Issue_AnonymousTypes_InAdditionalProperties_ShouldNotThrow()
{
// Anonymous types require reflection-based serialization
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
return;
}

// Exact minimal repro from the issue
AIContent c = new()
{
AdditionalProperties = new()
{
["data"] = new { X = 1.0, Y = 2.0 }
}
};

// This should not throw NotSupportedException
var exception = Record.Exception(() => c.ToContentBlock());

Assert.Null(exception);
}
}