Add tools support to sampling (#976)

This commit is contained in:
Stephen Toub 2025-11-20 09:52:23 -05:00 • committed by GitHub
commit 15139cc569
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1623 additions and 425 deletions

View file

@ -1,4 +1,3 @@
using Microsoft.Extensions.AI;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@ -37,7 +36,7 @@ public abstract class ContentBlock
/// When overridden in a derived class, gets the type of content.
/// </summary>
/// <remarks>
/// This determines the structure of the content object. Valid values include "image", "audio", "text", "resource", and "resource_link".
/// This determines the structure of the content object. Valid values include "image", "audio", "text", "resource", "resource_link", "tool_use", and "tool_result".
/// </remarks>
[JsonPropertyName("type")]
public abstract string Type { get; }
@ -52,6 +51,15 @@ public abstract class ContentBlock
[JsonPropertyName("annotations")]
public Annotations? Annotations { get; set; }
/// <summary>
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
/// </summary>
/// <remarks>
/// Implementations must not make assumptions about its contents.
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
/// <summary>
/// Provides a <see cref="JsonConverter"/> for <see cref="ContentBlock"/>.
/// </summary>
@ -84,6 +92,12 @@ public abstract class ContentBlock
ResourceContents? resource = null;
Annotations? annotations = null;
JsonObject? meta = null;
string? id = null;
JsonElement? input = null;
string? toolUseId = null;
List<ContentBlock>? content = null;
JsonElement? structuredContent = null;
bool? isError = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
@ -142,42 +156,71 @@ public abstract class ContentBlock
meta = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.JsonObject);
break;
case "id":
id = reader.GetString();
break;
case "input":
input = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.JsonElement);
break;
case "toolUseId":
toolUseId = reader.GetString();
break;
case "content":
if (reader.TokenType == JsonTokenType.StartArray)
{
content = [];
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
content.Add(Read(ref reader, typeof(ContentBlock), options) ??
throw new JsonException("Unexpected null item in content array."));
}
}
else
{
content = [Read(ref reader, typeof(ContentBlock), options) ??
throw new JsonException("Unexpected null content item.")];
}
break;
case "structuredContent":
structuredContent = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.JsonElement);
break;
case "isError":
isError = reader.GetBoolean();
break;
default:
reader.Skip();
break;
}
}
return type switch
ContentBlock block = type switch
{
"text" => new TextContentBlock
{
Text = text ?? throw new JsonException("Text contents must be provided for 'text' type."),
Annotations = annotations,
Meta = meta,
},
"image" => new ImageContentBlock
{
Data = data ?? throw new JsonException("Image data must be provided for 'image' type."),
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'image' type."),
Annotations = annotations,
Meta = meta,
},
"audio" => new AudioContentBlock
{
Data = data ?? throw new JsonException("Audio data must be provided for 'audio' type."),
MimeType = mimeType ?? throw new JsonException("MIME type must be provided for 'audio' type."),
Annotations = annotations,
Meta = meta,
},
"resource" => new EmbeddedResourceBlock
{
Resource = resource ?? throw new JsonException("Resource contents must be provided for 'resource' type."),
Annotations = annotations,
Meta = meta,
},
"resource_link" => new ResourceLinkBlock
@ -187,11 +230,30 @@ public abstract class ContentBlock
Description = description,
MimeType = mimeType,
Size = size,
Annotations = annotations,
},
"tool_use" => new ToolUseContentBlock
{
Id = id ?? throw new JsonException("ID must be provided for 'tool_use' type."),
Name = name ?? throw new JsonException("Name must be provided for 'tool_use' type."),
Input = input ?? throw new JsonException("Input must be provided for 'tool_use' type."),
},
"tool_result" => new ToolResultContentBlock
{
ToolUseId = toolUseId ?? throw new JsonException("ToolUseId must be provided for 'tool_result' type."),
Content = content ?? throw new JsonException("Content must be provided for 'tool_result' type."),
StructuredContent = structuredContent,
IsError = isError,
},
_ => throw new JsonException($"Unknown content type: '{type}'"),
};
block.Annotations = annotations;
block.Meta = meta;
return block;
}
/// <inheritdoc/>
@ -211,41 +273,21 @@ public abstract class ContentBlock
{
case TextContentBlock textContent:
writer.WriteString("text", textContent.Text);
if (textContent.Meta is not null)
{
writer.WritePropertyName("_meta");
JsonSerializer.Serialize(writer, textContent.Meta, McpJsonUtilities.JsonContext.Default.JsonObject);
}
break;
case ImageContentBlock imageContent:
writer.WriteString("data", imageContent.Data);
writer.WriteString("mimeType", imageContent.MimeType);
if (imageContent.Meta is not null)
{
writer.WritePropertyName("_meta");
JsonSerializer.Serialize(writer, imageContent.Meta, McpJsonUtilities.JsonContext.Default.JsonObject);
}
break;
case AudioContentBlock audioContent:
writer.WriteString("data", audioContent.Data);
writer.WriteString("mimeType", audioContent.MimeType);
if (audioContent.Meta is not null)
{
writer.WritePropertyName("_meta");
JsonSerializer.Serialize(writer, audioContent.Meta, McpJsonUtilities.JsonContext.Default.JsonObject);
}
break;
case EmbeddedResourceBlock embeddedResource:
writer.WritePropertyName("resource");
JsonSerializer.Serialize(writer, embeddedResource.Resource, McpJsonUtilities.JsonContext.Default.ResourceContents);
if (embeddedResource.Meta is not null)
{
writer.WritePropertyName("_meta");
JsonSerializer.Serialize(writer, embeddedResource.Meta, McpJsonUtilities.JsonContext.Default.JsonObject);
}
break;
case ResourceLinkBlock resourceLink:
@ -264,6 +306,33 @@ public abstract class ContentBlock
writer.WriteNumber("size", resourceLink.Size.Value);
}
break;
case ToolUseContentBlock toolUse:
writer.WriteString("id", toolUse.Id);
writer.WriteString("name", toolUse.Name);
writer.WritePropertyName("input");
JsonSerializer.Serialize(writer, toolUse.Input, McpJsonUtilities.JsonContext.Default.JsonElement);
break;
case ToolResultContentBlock toolResult:
writer.WriteString("toolUseId", toolResult.ToolUseId);
writer.WritePropertyName("content");
writer.WriteStartArray();
foreach (var item in toolResult.Content)
{
Write(writer, item, options);
}
writer.WriteEndArray();
if (toolResult.StructuredContent.HasValue)
{
writer.WritePropertyName("structuredContent");
JsonSerializer.Serialize(writer, toolResult.StructuredContent.Value, McpJsonUtilities.JsonContext.Default.JsonElement);
}
if (toolResult.IsError.HasValue)
{
writer.WriteBoolean("isError", toolResult.IsError.Value);
}
break;
}
if (value.Annotations is { } annotations)
@ -272,6 +341,12 @@ public abstract class ContentBlock
JsonSerializer.Serialize(writer, annotations, McpJsonUtilities.JsonContext.Default.Annotations);
}
if (value.Meta is not null)
{
writer.WritePropertyName("_meta");
JsonSerializer.Serialize(writer, value.Meta, McpJsonUtilities.JsonContext.Default.JsonObject);
}
writer.WriteEndObject();
}
}
@ -288,15 +363,6 @@ public sealed class TextContentBlock : ContentBlock
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; set; }
/// <summary>
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
/// </summary>
/// <remarks>
/// Implementations must not make assumptions about its contents.
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
}
/// <summary>Represents an image provided to or from an LLM.</summary>
@ -321,15 +387,6 @@ public sealed class ImageContentBlock : ContentBlock
/// </remarks>
[JsonPropertyName("mimeType")]
public required string MimeType { get; set; }
/// <summary>
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
/// </summary>
/// <remarks>
/// Implementations must not make assumptions about its contents.
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
}
/// <summary>Represents audio provided to or from an LLM.</summary>
@ -354,15 +411,6 @@ public sealed class AudioContentBlock : ContentBlock
/// </remarks>
[JsonPropertyName("mimeType")]
public required string MimeType { get; set; }
/// <summary>
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
/// </summary>
/// <remarks>
/// Implementations must not make assumptions about its contents.
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
}
/// <summary>Represents the contents of a resource, embedded into a prompt or tool call result.</summary>
@ -386,15 +434,6 @@ public sealed class EmbeddedResourceBlock : ContentBlock
/// </remarks>
[JsonPropertyName("resource")]
public required ResourceContents Resource { get; set; }
/// <summary>
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
/// </summary>
/// <remarks>
/// Implementations must not make assumptions about its contents.
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
}
/// <summary>Represents a resource that the server is capable of reading, included in a prompt or tool call result.</summary>
@ -463,3 +502,76 @@ public sealed class ResourceLinkBlock : ContentBlock
[JsonPropertyName("size")]
public long? Size { get; set; }
}
/// <summary>Represents a request from the assistant to call a tool.</summary>
public sealed class ToolUseContentBlock : ContentBlock
{
/// <inheritdoc/>
public override string Type => "tool_use";
/// <summary>
/// Gets or sets a unique identifier for this tool use.
/// </summary>
/// <remarks>
/// This ID is used to match tool results to their corresponding tool uses.
/// </remarks>
[JsonPropertyName("id")]
public required string Id { get; set; }
/// <summary>
/// Gets or sets the name of the tool to call.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; set; }
/// <summary>
/// Gets or sets the arguments to pass to the tool, conforming to the tool's input schema.
/// </summary>
[JsonPropertyName("input")]
public required JsonElement Input { get; set; }
}
/// <summary>Represents the result of a tool use, provided by the user back to the assistant.</summary>
public sealed class ToolResultContentBlock : ContentBlock
{
/// <inheritdoc/>
public override string Type => "tool_result";
/// <summary>
/// Gets or sets the ID of the tool use this result corresponds to.
/// </summary>
/// <remarks>
/// This must match the ID from a previous <see cref="ToolUseContentBlock"/>.
/// </remarks>
[JsonPropertyName("toolUseId")]
public required string ToolUseId { get; set; }
/// <summary>
/// Gets or sets the unstructured result content of the tool use.
/// </summary>
/// <remarks>
/// This has the same format as CallToolResult.Content and can include text, images,
/// audio, resource links, and embedded resources.
/// </remarks>
[JsonPropertyName("content")]
public required List<ContentBlock> Content { get; set; }
/// <summary>
/// Gets or sets an optional structured result object.
/// </summary>
/// <remarks>
/// If the tool defined an outputSchema, this should conform to that schema.
/// </remarks>
[JsonPropertyName("structuredContent")]
public JsonElement? StructuredContent { get; set; }
/// <summary>
/// Gets or sets whether the tool use resulted in an error.
/// </summary>
/// <remarks>
/// If true, the content typically describes the error that occurred.
/// Default: false
/// </remarks>
[JsonPropertyName("isError")]
public bool? IsError { get; set; }
}

View file

@ -6,7 +6,14 @@ namespace ModelContextProtocol.Protocol;
/// Specifies the context inclusion options for a request in the Model Context Protocol (MCP).
/// </summary>
/// <remarks>
/// <para>
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
/// </para>
/// <para>
/// <see cref="ContextInclusion"/>, and in particular <see cref="ThisServer"/> and <see cref="AllServers"/>, are deprecated.
/// Servers should only use these values if the client declares <see cref="ClientCapabilities.Sampling"/> with
/// <see cref="SamplingCapability.Context"/> set. These values may be removed in future spec releases.
/// </para>
/// </remarks>
[JsonConverter(typeof(JsonStringEnumConverter<ContextInclusion>))]
public enum ContextInclusion
@ -20,12 +27,20 @@ public enum ContextInclusion
/// <summary>
/// Indicates that context from the server that sent the request should be included.
/// </summary>
/// <remarks>
/// This value is soft-deprecated. Servers should only use this value if the client
/// declares ClientCapabilities.Sampling.Context.
/// </remarks>
[JsonStringEnumMemberName("thisServer")]
ThisServer,
/// <summary>
/// Indicates that context from all servers that the client is connected to should be included.
/// </summary>
/// <remarks>
/// This value is soft-deprecated. Servers should only use this value if the client
/// declares ClientCapabilities.Sampling.Context.
/// </remarks>
[JsonStringEnumMemberName("allServers")]
AllServers
}

View file

@ -16,7 +16,15 @@ public sealed class CreateMessageRequestParams : RequestParams
/// Gets or sets an indication as to which server contexts should be included in the prompt.
/// </summary>
/// <remarks>
/// <para>
/// The client may ignore this request.
/// </para>
/// <para>
/// <see cref="ContextInclusion"/>, and in particular <see cref="ContextInclusion.ThisServer"/> and
/// <see cref="ContextInclusion.AllServers"/>, are deprecated. Servers should only use these values if the client
/// declares <see cref="ClientCapabilities.Sampling"/> with <see cref="SamplingCapability.Context"/> set.
/// These values may be removed in future spec releases.
/// </para>
/// </remarks>
[JsonPropertyName("includeContext")]
public ContextInclusion? IncludeContext { get; set; }
@ -100,4 +108,16 @@ public sealed class CreateMessageRequestParams : RequestParams
/// </summary>
[JsonPropertyName("temperature")]
public float? Temperature { get; set; }
/// <summary>
/// Gets or sets tools that the model may use during generation.
/// </summary>
[JsonPropertyName("tools")]
public IList<Tool>? Tools { get; set; }
/// <summary>
/// Gets or sets controls for how the model uses tools.
/// </summary>
[JsonPropertyName("toolChoice")]
public ToolChoice? ToolChoice { get; set; }
}

View file

@ -11,10 +11,14 @@ namespace ModelContextProtocol.Protocol;
public sealed class CreateMessageResult : Result
{
/// <summary>
/// Gets or sets the content of the message.
/// Gets or sets the content of the assistant's response.
/// </summary>
/// <remarks>
/// In the corresponding JSON, this may be a single content block or an array of content blocks.
/// </remarks>
[JsonPropertyName("content")]
public required ContentBlock Content { get; set; }
[JsonConverter(typeof(SingleItemOrListConverter<ContentBlock>))]
public required IList<ContentBlock> Content { get; set; }
/// <summary>
/// Gets or sets the name of the model that generated the message.
@ -35,12 +39,14 @@ public sealed class CreateMessageResult : Result
/// Gets or sets the reason why message generation (sampling) stopped, if known.
/// </summary>
/// <remarks>
/// Common values include:
/// Standard values include:
/// <list type="bullet">
/// <item><term>endTurn</term><description>The model naturally completed its response.</description></item>
/// <item><term>maxTokens</term><description>The response was truncated due to reaching token limits.</description></item>
/// <item><term>stopSequence</term><description>A specific stop sequence was encountered during generation.</description></item>
/// <item><term>toolUse</term><description>The model wants to use one or more tools.</description></item>
/// </list>
/// This field is an open string to allow for provider-specific stop reasons.
/// </remarks>
[JsonPropertyName("stopReason")]
public string? StopReason { get; set; }
@ -49,5 +55,17 @@ public sealed class CreateMessageResult : Result
/// Gets or sets the role of the user who generated the message.
/// </summary>
[JsonPropertyName("role")]
public required Role Role { get; set; }
public Role Role { get; set; } = Role.Assistant;
/// <summary>The stop reason "endTurn".</summary>
internal const string StopReasonEndTurn = "endTurn";
/// <summary>The stop reason "maxTokens".</summary>
internal const string StopReasonMaxTokens = "maxTokens";
/// <summary>The stop reason "stopSequence".</summary>
internal const string StopReasonStopSequence = "stopSequence";
/// <summary>The stop reason "toolUse".</summary>
internal const string StopReasonToolUse = "toolUse";
}

View file

@ -16,11 +16,6 @@ namespace ModelContextProtocol.Protocol;
/// When this capability is enabled, an MCP server can request the client to generate content
/// using an AI model. The client must set a <see cref="McpClientHandlers.SamplingHandler"/> to process these requests.
/// </para>
/// <para>
/// This class is intentionally empty as the Model Context Protocol specification does not
/// currently define additional properties for sampling capabilities. Future versions of the
/// specification may extend this capability with additional configuration options.
/// </para>
/// </remarks>
public sealed class SamplingCapability
{
@ -38,7 +33,7 @@ public sealed class SamplingCapability
/// generated content.
/// </para>
/// <para>
/// You can create a handler using the <see cref="McpClient.CreateSamplingHandler"/> extension
/// You can create a handler using the <see cref="AIContentExtensions.CreateSamplingHandler"/> extension
/// method with any implementation of <see cref="IChatClient"/>.
/// </para>
/// </remarks>
@ -46,4 +41,20 @@ public sealed class SamplingCapability
[Obsolete($"Use {nameof(McpClientOptions.Handlers.SamplingHandler)} instead. This member will be removed in a subsequent release.")] // See: https://github.com/modelcontextprotocol/csharp-sdk/issues/774
[EditorBrowsable(EditorBrowsableState.Never)]
public Func<CreateMessageRequestParams?, IProgress<ProgressNotificationValue>, CancellationToken, ValueTask<CreateMessageResult>>? SamplingHandler { get; set; }
}
/// <summary>
/// Gets or sets whether the client supports context inclusion via includeContext parameter.
/// </summary>
/// <remarks>
/// If not declared, servers should only use includeContext: "none".
/// </remarks>
[JsonPropertyName("context")]
public SamplingContextCapability? Context { get; set; }
/// <summary>
/// Gets or sets whether the client supports tool use via tools and toolChoice parameters.
/// </summary>
[JsonPropertyName("tools")]
public SamplingToolsCapability? Tools { get; set; }
}

View file

@ -0,0 +1,6 @@
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Represents the sampling context capability.
/// </summary>
public sealed class SamplingContextCapability;

View file

@ -1,3 +1,4 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
@ -8,7 +9,8 @@ namespace ModelContextProtocol.Protocol;
/// <remarks>
/// <para>
/// A <see cref="SamplingMessage"/> encapsulates content sent to or received from AI models in the Model Context Protocol.
/// Each message has a specific role (<see cref="Role.User"/> or <see cref="Role.Assistant"/>) and contains content which can be text or images.
/// The message has a role (<see cref="Role.User"/> or <see cref="Role.Assistant"/>) and content which can be text, images,
/// audio, tool uses, or tool results.
/// </para>
/// <para>
/// <see cref="SamplingMessage"/> objects are typically used in collections within <see cref="CreateMessageRequestParams"/>
@ -16,8 +18,9 @@ namespace ModelContextProtocol.Protocol;
/// within the Model Context Protocol.
/// </para>
/// <para>
/// While similar to <see cref="PromptMessage"/>, the <see cref="SamplingMessage"/> is focused on direct LLM sampling
/// operations rather than the enhanced resource embedding capabilities provided by <see cref="PromptMessage"/>.
/// If content contains any <see cref="ToolResultContentBlock"/>, then all content items
/// must be <see cref="ToolResultContentBlock"/>. Tool results cannot be mixed with text, image, or
/// audio content in the same message.
/// </para>
/// <para>
/// See the <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">schema</see> for details.
@ -29,11 +32,21 @@ public sealed class SamplingMessage
/// Gets or sets the content of the message.
/// </summary>
[JsonPropertyName("content")]
public required ContentBlock Content { get; set; }
[JsonConverter(typeof(SingleItemOrListConverter<ContentBlock>))]
public required IList<ContentBlock> Content { get; set; }
/// <summary>
/// Gets or sets the role of the message sender, indicating whether it's from a "user" or an "assistant".
/// Gets or sets the role of the message sender.
/// </summary>
[JsonPropertyName("role")]
public required Role Role { get; set; }
public Role Role { get; set; } = Role.User;
/// <summary>
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
/// </summary>
/// <remarks>
/// Implementations must not make assumptions about its contents.
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
}

View file

@ -0,0 +1,6 @@
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Represents the sampling tools capability.
/// </summary>
public sealed class SamplingToolsCapability;

View file

@ -0,0 +1,67 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// JSON converter for <see cref="IList{T}"/> that handles both array and single object representations.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class SingleItemOrListConverter<T> : JsonConverter<IList<T>>
where T : class
{
/// <inheritdoc />
public override IList<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.StartArray)
{
List<T> list = [];
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (JsonSerializer.Deserialize(ref reader, options.GetTypeInfo(typeof(T))) is T item)
{
list.Add(item);
}
}
return list;
}
if (reader.TokenType == JsonTokenType.StartObject)
{
return JsonSerializer.Deserialize(ref reader, options.GetTypeInfo(typeof(T))) is T item ? [item] : [];
}
throw new JsonException($"Unexpected token type: {reader.TokenType}. Expected StartArray or StartObject.");
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, IList<T> value, JsonSerializerOptions options)
{
switch (value)
{
case null:
writer.WriteNullValue();
return;
case { Count: 1 }:
JsonSerializer.Serialize(writer, value[0], options.GetTypeInfo(typeof(object)));
return;
default:
writer.WriteStartArray();
foreach (var item in value)
{
JsonSerializer.Serialize(writer, item, options.GetTypeInfo(typeof(object)));
}
writer.WriteEndArray();
return;
}
}
}

View file

@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Controls tool selection behavior for sampling requests.
/// </summary>
public sealed class ToolChoice
{
/// <summary>
/// Gets or sets the mode controlling which tools the model can call.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><term>"auto"</term><description>Model decides whether to call tools (default)</description></item>
/// <item><term>"required"</term><description>Model must call at least one tool</description></item>
/// <item><term>"none"</term><description>Model must not call any tools</description></item>
/// </list>
/// </remarks>
[JsonPropertyName("mode")]
public string? Mode { get; set; }
/// <summary>The mode value "auto".</summary>
internal const string ModeAuto = "auto";
/// <summary>The mode value "required".</summary>
internal const string ModeRequired = "required";
/// <summary>The mode value "none".</summary>
internal const string ModeNone = "none";
}