Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
157 changes: 157 additions & 0 deletions tests/ModelContextProtocol.Tests/AIContentExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,161 @@ public void ToAIContent_ToolResultToFunctionResultRoundTrip()
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"));
}
}