Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
11 changes: 7 additions & 4 deletions src/ModelContextProtocol.Core/AIContentExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
#if !NET
using System.Runtime.InteropServices;
#endif
Expand Down Expand Up @@ -138,8 +139,10 @@ public static class AIContentExtensions
}

/// <summary>Converts the specified dictionary to a <see cref="JsonObject"/>.</summary>
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "DefaultOptions includes fallback to reflection-based serialization when available.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "DefaultOptions includes fallback to reflection-based serialization when available.")]
internal static JsonObject? ToJsonObject(this IReadOnlyDictionary<string, object?> properties) =>
JsonSerializer.SerializeToNode(properties, McpJsonUtilities.JsonContext.Default.IReadOnlyDictionaryStringObject) as JsonObject;
JsonSerializer.SerializeToNode(properties, typeof(IReadOnlyDictionary<string, object?>), McpJsonUtilities.DefaultOptions) as JsonObject;

internal static AdditionalPropertiesDictionary ToAdditionalProperties(this JsonObject obj)
{
Expand Down Expand Up @@ -271,7 +274,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 Expand Up @@ -414,13 +417,13 @@ public static ContentBlock ToContentBlock(this AIContent content)
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>()) }],
[new TextContentBlock { Text = JsonSerializer.Serialize(resultContent.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo(resultContent.Result?.GetType() ?? typeof(object))) }],
StructuredContent = resultContent.Result is JsonElement je ? je : null,
},

_ => new TextContentBlock
{
Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))),
Text = $"[Unsupported AIContent type: {content.GetType().Name}]",
}
};

Expand Down
8 changes: 6 additions & 2 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ public static partial class McpJsonUtilities
/// Creates default options to use for MCP-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Fallback resolver is added only when reflection is enabled or when processing user-defined types that may require reflection.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Fallback resolver is added only when reflection is enabled or when processing user-defined types that may require reflection.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
Expand All @@ -44,6 +44,10 @@ private static JsonSerializerOptions CreateDefaultOptions()
// Chain with all supported types from MEAI.
options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!);

// Add a fallback reflection-based resolver for types not covered by source generators.
// This allows serialization of user-defined types, including anonymous types in AdditionalProperties.
options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver());

// Add a converter for user-defined enums, if reflection is enabled by default.
if (JsonSerializer.IsReflectionEnabledByDefault)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;

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.
/// </summary>
public class AIContentExtensionsAnonymousTypeTests
{
[Fact]
public void ToContentBlock_WithAnonymousTypeInAdditionalProperties_DoesNotThrow()
{
// 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()
{
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()
{
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()
{
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()
{
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()
{
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"));
}
}
7 changes: 5 additions & 2 deletions tests/ModelContextProtocol.Tests/McpJsonUtilitiesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ public static void DefaultOptions_IsSingleton()
}

[Fact]
public static void DefaultOptions_UseReflectionWhenEnabled()
public static void DefaultOptions_SupportsAnonymousTypes()
{
// DefaultOptions includes a fallback DefaultJsonTypeInfoResolver to support
// serialization of user-defined types including anonymous types, regardless
// of the IsReflectionEnabledByDefault setting.
var options = McpJsonUtilities.DefaultOptions;
Type anonType = new { Id = 42 }.GetType();

Assert.Equal(JsonSerializer.IsReflectionEnabledByDefault, options.TryGetTypeInfo(anonType, out _));
Assert.True(options.TryGetTypeInfo(anonType, out _));
}

[Fact]
Expand Down
31 changes: 31 additions & 0 deletions tests/ModelContextProtocol.Tests/RegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Microsoft.Extensions.AI;

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()
{
// 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);
}
}