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
10 changed files with 366 additions and 42 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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