csharp-sdk/src/ModelContextProtocol.Core/AIContentExtensions.cs
Copilot 80b1ad21d7
Use ReadOnlyMemory<byte> for binary data to eliminate UTF-16 transcoding (#1070)
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: ericstj <8918108+ericstj@users.noreply.github.com>
Co-authored-by: Eric StJohn <ericstj@microsoft.com>
2026-02-19 14:08:05 -08:00

473 lines
22 KiB
C#

using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol;
/// <summary>
/// Provides extension methods for converting between Model Context Protocol (MCP) types and Microsoft.Extensions.AI types.
/// </summary>
/// <remarks>
/// This class serves as an adapter layer between Model Context Protocol (MCP) types and the <see cref="AIContent"/> model types
/// from the Microsoft.Extensions.AI namespace.
/// </remarks>
public static class AIContentExtensions
{
/// <summary>
/// Creates a sampling handler for use with <see cref="McpClientHandlers.SamplingHandler"/> that will
/// 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>
/// This method creates a function that converts MCP message requests into chat client calls, enabling
/// an MCP client to generate text or other content using an actual AI model via the provided chat client.
/// </para>
/// <para>
/// The handler can process text messages, image messages, resource messages, and tool use/results as defined in the
/// Model Context Protocol.
/// </para>
/// </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,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(chatClient);
serializerOptions ??= McpJsonUtilities.DefaultOptions;
return async (requestParams, progress, cancellationToken) =>
{
Throw.IfNull(requestParams);
var (messages, options) = ToChatClientArguments(requestParams, serializerOptions);
var progressToken = requestParams.ProgressToken;
List<ChatResponseUpdate> updates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
if (progressToken is not null)
{
progress.Report(new() { Progress = updates.Count });
}
}
ChatResponse? chatResponse = updates.ToChatResponse();
ChatMessage? lastMessage = chatResponse.Messages.LastOrDefault();
IList<ContentBlock>? contents = lastMessage?.Contents.Select(c => c.ToContentBlock()).ToList();
if (contents is not { Count: > 0 })
{
(contents ??= []).Add(new TextContentBlock() { Text = "" });
}
return new()
{
Model = chatResponse.ModelId ?? "",
StopReason =
chatResponse.FinishReason == ChatFinishReason.Stop ? CreateMessageResult.StopReasonEndTurn :
chatResponse.FinishReason == ChatFinishReason.Length ? CreateMessageResult.StopReasonMaxTokens :
chatResponse.FinishReason == ChatFinishReason.ToolCalls ? CreateMessageResult.StopReasonToolUse :
chatResponse.FinishReason.ToString(),
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, JsonSerializerOptions serializerOptions)
{
ChatOptions? options = null;
if (requestParams.MaxTokens is int maxTokens)
{
(options ??= new()).MaxOutputTokens = maxTokens;
}
if (requestParams.Temperature is float temperature)
{
(options ??= new()).Temperature = temperature;
}
if (requestParams.StopSequences is { } stopSequences)
{
(options ??= new()).StopSequences = stopSequences.ToArray();
}
if (requestParams.SystemPrompt is { } systemPrompt)
{
(options ??= new()).Instructions = systemPrompt;
}
if (requestParams.Tools is { } tools)
{
foreach (var tool in tools)
{
((options ??= new()).Tools ??= []).Add(new ToolAIFunctionDeclaration(tool));
}
if (options.Tools is { Count: > 0 } && requestParams.ToolChoice is { } toolChoice)
{
options.ToolMode = toolChoice.Mode switch
{
ToolChoice.ModeAuto => ChatToolMode.Auto,
ToolChoice.ModeRequired => ChatToolMode.RequireAny,
ToolChoice.ModeNone => ChatToolMode.None,
_ => null,
};
}
}
List<ChatMessage> messages = [];
foreach (var sm in requestParams.Messages)
{
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 :
sm.Role is Role.Assistant ? ChatRole.Assistant :
ChatRole.User;
messages.Add(new ChatMessage(role, aiContents));
}
}
return (messages, options);
}
};
}
/// <summary>Converts the specified dictionary to a <see cref="JsonObject"/>.</summary>
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)
{
AdditionalPropertiesDictionary d = [];
foreach (var kvp in obj)
{
d.Add(kvp.Key, kvp.Value);
}
return d;
}
/// <summary>
/// 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, JsonSerializerOptions? options = null)
{
Throw.IfNull(promptMessage);
AIContent? content = promptMessage.Content.ToAIContent(options);
return new()
{
RawRepresentation = promptMessage,
Role = promptMessage.Role == Role.User ? ChatRole.User : ChatRole.Assistant,
Contents = content is not null ? [content] : [],
};
}
/// <summary>
/// Converts a <see cref="CallToolResult"/> to a <see cref="ChatMessage"/> object.
/// </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
/// into a standard <see cref="ChatMessage"/> object that can be used with AI client libraries. It produces a
/// <see cref="ChatRole.Tool"/> message containing a <see cref="FunctionResultContent"/> with result as a
/// 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, JsonSerializerOptions? options = null)
{
Throw.IfNull(result);
Throw.IfNull(callId);
options ??= McpJsonUtilities.DefaultOptions;
return new(ChatRole.Tool, [new FunctionResultContent(callId, JsonSerializer.SerializeToElement(result, options.GetTypeInfo<CallToolResult>()))
{
RawRepresentation = result,
}]);
}
/// <summary>
/// Converts a <see cref="GetPromptResult"/> to a list of <see cref="ChatMessage"/> objects.
/// </summary>
/// <param name="promptResult">The prompt result containing messages to convert.</param>
/// <returns>A list of <see cref="ChatMessage"/> objects created from the prompt messages.</returns>
/// <remarks>
/// This method transforms protocol-specific <see cref="PromptMessage"/> objects from a Model Context Protocol
/// prompt result into standard <see cref="ChatMessage"/> objects that can be used with AI client libraries.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="promptResult"/> is <see langword="null"/>.</exception>
public static IList<ChatMessage> ToChatMessages(this GetPromptResult promptResult)
{
Throw.IfNull(promptResult);
return promptResult.Messages.Select(m => m.ToChatMessage()).ToList();
}
/// <summary>
/// Converts a <see cref="ChatMessage"/> to a list of <see cref="PromptMessage"/> objects.
/// </summary>
/// <param name="chatMessage">The chat message to convert.</param>
/// <returns>A list of <see cref="PromptMessage"/> objects created from the chat message's contents.</returns>
/// <remarks>
/// This method transforms standard <see cref="ChatMessage"/> objects used with AI client libraries into
/// protocol-specific <see cref="PromptMessage"/> objects for the Model Context Protocol system.
/// Only representable content items are processed.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="chatMessage"/> is <see langword="null"/>.</exception>
public static IList<PromptMessage> ToPromptMessages(this ChatMessage chatMessage)
{
Throw.IfNull(chatMessage);
Role r = chatMessage.Role == ChatRole.User ? Role.User : Role.Assistant;
List<PromptMessage> messages = [];
foreach (var content in chatMessage.Contents)
{
if (content is TextContent or DataContent)
{
messages.Add(new PromptMessage { Role = r, Content = content.ToContentBlock() });
}
}
return messages;
}
/// <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>
/// <remarks>
/// This method converts Model Context Protocol content types to the equivalent Microsoft.Extensions.AI
/// 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, JsonSerializerOptions? options = null)
{
Throw.IfNull(content);
options ??= McpJsonUtilities.DefaultOptions;
AIContent? ac = content switch
{
TextContentBlock textContent => new TextContent(textContent.Text),
ImageContentBlock imageContent => new DataContent(imageContent.DecodedData, imageContent.MimeType),
AudioContentBlock audioContent => new DataContent(audioContent.DecodedData, audioContent.MimeType),
EmbeddedResourceBlock resourceContent => resourceContent.Resource.ToAIContent(),
ToolUseContentBlock toolUse => FunctionCallContent.CreateFromParsedArguments(toolUse.Input, toolUse.Id, toolUse.Name,
json => JsonSerializer.Deserialize(json, options.GetTypeInfo<IDictionary<string, object?>>())),
ToolResultContentBlock toolResult => new FunctionResultContent(
toolResult.ToolUseId,
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,
},
_ => null,
};
if (ac is not null)
{
ac.RawRepresentation = content;
ac.AdditionalProperties = content.Meta?.ToAdditionalProperties();
}
return ac;
}
/// <summary>Creates a new <see cref="AIContent"/> from the content of a <see cref="ResourceContents"/>.</summary>
/// <param name="content">The <see cref="ResourceContents"/> to convert.</param>
/// <returns>The created <see cref="AIContent"/>.</returns>
/// <remarks>
/// This method converts Model Context Protocol resource types to the equivalent Microsoft.Extensions.AI
/// content types, enabling seamless integration between the protocol and AI client libraries.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="content"/> is <see langword="null"/>.</exception>
/// <exception cref="NotSupportedException">The resource type is not supported.</exception>
public static AIContent ToAIContent(this ResourceContents content)
{
Throw.IfNull(content);
AIContent ac = content switch
{
BlobResourceContents blobResource => new DataContent(blobResource.DecodedData, blobResource.MimeType ?? "application/octet-stream"),
TextResourceContents textResource => new TextContent(textResource.Text),
_ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.")
};
(ac.AdditionalProperties ??= [])["uri"] = content.Uri;
ac.RawRepresentation = content;
return ac;
}
/// <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>
/// This method converts a collection of Model Context Protocol content objects into a collection of
/// Microsoft.Extensions.AI content objects. It's useful when working with multiple content items, such as
/// when processing the contents of a message or response.
/// </para>
/// <para>
/// 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, JsonSerializerOptions? options = null)
{
Throw.IfNull(contents);
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>
/// <param name="contents">The <see cref="ResourceContents"/> instances to convert.</param>
/// <returns>A list of <see cref="AIContent"/> objects created from the resource contents.</returns>
/// <remarks>
/// <para>
/// This method converts a collection of Model Context Protocol resource objects into a collection of
/// Microsoft.Extensions.AI content objects. It's useful when working with multiple resources, such as
/// when processing the contents of a <see cref="ReadResourceResult"/>.
/// </para>
/// <para>
/// Each <see cref="ResourceContents"/> object is converted using <see cref="ToAIContent(ResourceContents)"/>,
/// preserving the type-specific conversion logic: text resources become <see cref="TextContentBlock"/> objects and
/// binary resources become <see cref="DataContent"/> objects.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="contents"/> is <see langword="null"/>.</exception>
public static IList<AIContent> ToAIContents(this IEnumerable<ResourceContents> contents)
{
Throw.IfNull(contents);
return [.. contents.Select(ToAIContent)];
}
/// <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, JsonSerializerOptions? options = null)
{
Throw.IfNull(content);
options ??= McpJsonUtilities.DefaultOptions;
ContentBlock contentBlock = content switch
{
TextContent textContent => new TextContentBlock
{
Text = textContent.Text,
},
DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentBlock
{
Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
},
DataContent dataContent when dataContent.HasTopLevelMediaType("audio") => new AudioContentBlock
{
Data = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
},
DataContent dataContent => new EmbeddedResourceBlock
{
Resource = new BlobResourceContents
{
Blob = EncodingUtilities.GetUtf8Bytes(dataContent.Base64Data.Span),
MimeType = dataContent.MediaType,
Uri = string.Empty,
}
},
FunctionCallContent callContent => new ToolUseContentBlock()
{
Id = callContent.CallId,
Name = callContent.Name,
Input = JsonSerializer.SerializeToElement(callContent.Arguments, options.GetTypeInfo<IDictionary<string, object?>>()!),
},
FunctionResultContent resultContent => new ToolResultContentBlock()
{
ToolUseId = resultContent.CallId,
IsError = resultContent.Exception is not null,
Content =
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, options.GetTypeInfo(typeof(object))),
}
};
contentBlock.Meta = content.AdditionalProperties?.ToJsonObject(options);
return contentBlock;
}
private sealed class ToolAIFunctionDeclaration(Tool tool) : AIFunctionDeclaration
{
public override string Name => tool.Name;
public override string Description => tool.Description ?? "";
public override IReadOnlyDictionary<string, object?> AdditionalProperties =>
field ??= tool.Meta is { } meta ? meta.ToDictionary(p => p.Key, p => (object?)p.Value) : [];
public override JsonElement JsonSchema => tool.InputSchema;
public override JsonElement? ReturnJsonSchema => tool.OutputSchema;
public override object? GetService(Type serviceType, object? serviceKey = null)
{
Throw.IfNull(serviceType);
return
serviceKey is null && serviceType.IsInstanceOfType(tool) ? tool :
base.GetService(serviceType, serviceKey);
}
}
}