Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
58 changes: 58 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;

namespace ModelContextProtocol.Client;

Expand Down Expand Up @@ -913,6 +914,63 @@ public ValueTask<CallToolResult> CallToolAsync(
cancellationToken: cancellationToken);
}

/// <summary>
/// Invokes a tool on the server and deserializes the result as <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type to deserialize the tool's structured content or text content into.</typeparam>
/// <param name="toolName">The name of the tool to call on the server.</param>
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
/// <param name="progress">An optional progress reporter for server notifications.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The deserialized content of the tool result.</returns>
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed, the server returned an error response, or <see cref="CallToolResult.IsError"/> is <see langword="true"/>.</exception>
/// <exception cref="JsonException">The result content could not be deserialized as <typeparamref name="T"/>.</exception>
/// <remarks>
/// <para>
/// This method calls the existing <see cref="CallToolAsync(string, IReadOnlyDictionary{string, object?}?, IProgress{ProgressNotificationValue}?, RequestOptions?, CancellationToken)"/>
/// and then deserializes the result. If the result has <see cref="CallToolResult.StructuredContent"/>, that is deserialized
/// as <typeparamref name="T"/>. Otherwise, if the result has text content, the text of the first <see cref="TextContentBlock"/>
/// is deserialized as <typeparamref name="T"/>.
/// </para>
/// <para>
/// If <see cref="CallToolResult.IsError"/> is <see langword="true"/>, an <see cref="McpException"/> is thrown. To inspect
/// error details without an exception, use the non-generic <see cref="CallToolAsync(string, IReadOnlyDictionary{string, object?}?, IProgress{ProgressNotificationValue}?, RequestOptions?, CancellationToken)"/> overload instead.
/// </para>
/// </remarks>
public async ValueTask<T?> CallToolAsync<T>(
string toolName,
IReadOnlyDictionary<string, object?>? arguments = null,
IProgress<ProgressNotificationValue>? progress = null,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false);

if (result.IsError is true)
{
string errorMessage = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "Tool call failed.";
throw new McpException(errorMessage);
}

var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
JsonTypeInfo<T> typeInfo = (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T));

// Prefer StructuredContent if available, otherwise fall back to text content
if (result.StructuredContent is { } structuredContent)
{
return JsonSerializer.Deserialize(structuredContent, typeInfo);
}

if (result.Content.OfType<TextContentBlock>().FirstOrDefault() is { } textContent)
{
return JsonSerializer.Deserialize(textContent.Text, typeInfo);
}

return default;
}

/// <summary>
/// Invokes a tool on the server as a task for long-running operations.
/// </summary>
Expand Down
76 changes: 76 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Text.Json;
using System.Text.Json.Nodes;

namespace ModelContextProtocol.Protocol;

/// <summary>
/// Represents a strongly-typed result of a <see cref="RequestMethods.ToolsCall"/> request.
/// </summary>
/// <typeparam name="T">
/// The type of the structured content returned by the tool. This type is used to infer the
/// <see cref="Tool.OutputSchema"/> advertised by the tool.
/// </typeparam>
/// <remarks>
/// <para>
/// <see cref="CallToolResult{T}"/> provides a way to return strongly-typed structured content from a tool
/// while still providing access to <see cref="Result.Meta"/> and <see cref="IsError"/>. When a tool method
/// returns <see cref="CallToolResult{T}"/>, the SDK uses <typeparamref name="T"/> to infer the output schema
/// and serializes <see cref="Content"/> as both the text content and structured content of the response.
/// </para>
/// <para>
/// This type is a peer of <see cref="CallToolResult"/>, not a subclass. Use <see cref="CallToolResult"/> when
/// you need full control over individual content blocks, and <see cref="CallToolResult{T}"/> when you want
/// the SDK to handle serialization of a strongly-typed result.
/// </para>
/// </remarks>
public sealed class CallToolResult<T> : Result, ICallToolResultTyped
{
/// <summary>
/// Gets or sets the typed content returned by the tool.
/// </summary>
public T? Content { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether the tool call was unsuccessful.
/// </summary>
/// <value>
/// <see langword="true"/> to signify that the tool execution failed; <see langword="false"/> if it was successful.
/// </value>
/// <remarks>
/// <para>
/// Tool execution errors (including input validation errors, API failures, and business logic errors)
/// are reported with this property set to <see langword="true"/> and details in the <see cref="Content"/>
/// property, rather than as protocol-level errors.
/// </para>
/// <para>
/// This design allows language models to receive detailed error feedback and potentially self-correct
/// in subsequent requests.
/// </para>
/// </remarks>
public bool? IsError { get; set; }

/// <inheritdoc/>
CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions)
{
JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, serializerOptions.GetTypeInfo(typeof(T)));

return new()
{
Content = [new TextContentBlock { Text = structuredContent?.ToString() ?? "null" }],
StructuredContent = structuredContent,
IsError = IsError,
Meta = Meta,
};
}
}

/// <summary>
/// Internal interface for converting strongly-typed tool results to <see cref="CallToolResult"/>.
/// </summary>
internal interface ICallToolResultTyped
{
/// <summary>
/// Converts the strongly-typed result to a <see cref="CallToolResult"/>.
/// </summary>
CallToolResult ToCallToolResult(JsonSerializerOptions serializerOptions);
}
61 changes: 49 additions & 12 deletions src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
{
McpServerToolCreateOptions newOptions = options?.Clone() ?? new();

bool useStructuredContent = false;
if (method.GetCustomAttribute<McpServerToolAttribute>() is { } toolAttr)
{
newOptions.Name ??= toolAttr.Name;
Expand Down Expand Up @@ -204,13 +205,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
newOptions.Icons = [new() { Source = iconSource }];
}

newOptions.UseStructuredContent = toolAttr.UseStructuredContent;

if (toolAttr._taskSupport is { } taskSupport)
{
newOptions.Execution ??= new ToolExecution();
newOptions.Execution.TaskSupport ??= taskSupport;
}

useStructuredContent = toolAttr.UseStructuredContent;
}

if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
Expand All @@ -221,6 +222,22 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
// Set metadata if not already provided
newOptions.Metadata ??= CreateMetadata(method);

// Generate the output schema from the return type if needed.
// UseStructuredContent on the attribute uses T from CallToolResult<T> or the return type directly.
// CallToolResult<T> return types automatically infer the schema from T even without UseStructuredContent.
Type? outputSchemaType = GetCallToolResultContentType(method.ReturnType);
if (outputSchemaType is null && useStructuredContent)
{
outputSchemaType = method.ReturnType;
}

if (outputSchemaType is not null)
{
newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType,
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
inferenceOptions: newOptions.SchemaCreateOptions);
}

return newOptions;
}

Expand Down Expand Up @@ -305,6 +322,8 @@ public override async ValueTask<CallToolResult> InvokeAsync(

CallToolResult callToolResponse => callToolResponse,

ICallToolResultTyped typed => typed.ToCallToolResult(AIFunction.JsonSerializerOptions),

_ => new()
{
Content = [new TextContentBlock { Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))) }],
Expand Down Expand Up @@ -361,6 +380,29 @@ private static bool IsAsyncMethod(MethodInfo method)
return false;
}

/// <summary>
/// If the specified type is <see cref="CallToolResult{T}"/> (possibly wrapped in <see cref="Task{TResult}"/>
/// or <see cref="ValueTask{TResult}"/>), returns the <c>T</c> type argument. Otherwise, returns <see langword="null"/>.
/// </summary>
private static Type? GetCallToolResultContentType(Type returnType)
{
if (returnType.IsGenericType)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot why can't all of this schema handling for CallToolResult be done where the rest of the schema detection is done? There's already code unwrapping tasks, creating return schema for the return type, etc. I'm missing why this needs to be done separately.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated in 24bbac3 — unified into a single block after all attribute handling:

Type? outputSchemaType = GetCallToolResultContentType(method.ReturnType);
if (outputSchemaType is null && useStructuredContent)
{
    outputSchemaType = method.ReturnType;
}
if (outputSchemaType is not null)
{
    newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, ...);
}

GetCallToolResultContentType always runs first (handles CallToolResult<T> with or without the attribute), then UseStructuredContent fills in the return type only when CallToolResult<T> wasn't detected.

{
Type genericDef = returnType.GetGenericTypeDefinition();
if (genericDef == typeof(Task<>) || genericDef == typeof(ValueTask<>))
{
returnType = returnType.GetGenericArguments()[0];
}
}

if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(CallToolResult<>))
{
return returnType.GetGenericArguments()[0];
}

return null;
}

/// <summary>Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item.</summary>
internal static IReadOnlyList<object> CreateMetadata(MethodInfo method)
{
Expand Down Expand Up @@ -432,16 +474,16 @@ private static void ValidateToolName(string name)
/// Gets the tool description, synthesizing from both the function description and return description when appropriate.
/// </summary>
/// <remarks>
/// When UseStructuredContent is true, the return description is included in the output schema.
/// When UseStructuredContent is false (default), if there's a return description in the ReturnJsonSchema,
/// When an output schema is present, the return description is included in the output schema.
/// When no output schema is present (default), if there's a return description in the ReturnJsonSchema,
/// it will be appended to the tool description so the information is still available to consumers.
/// </remarks>
private static string? GetToolDescription(AIFunction function, McpServerToolCreateOptions? options)
{
string? description = options?.Description ?? function.Description;

// If structured content is enabled, the return description will be in the output schema
if (options?.UseStructuredContent is true)
// If structured content is enabled (output schema present), the return description will be in the output schema
if (options?.OutputSchema is not null)
{
return description;
}
Expand Down Expand Up @@ -483,12 +525,7 @@ schema.ValueKind is not JsonValueKind.Object ||
{
structuredOutputRequiresWrapping = false;

if (toolCreateOptions?.UseStructuredContent is not true)
{
return null;
}

if (function.ReturnJsonSchema is not JsonElement outputSchema)
if (toolCreateOptions?.OutputSchema is not JsonElement outputSchema)
{
return null;
}
Expand Down
9 changes: 9 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ namespace ModelContextProtocol.Server;
/// <description>Returned directly without modification.</description>
/// </item>
/// <item>
/// <term><see cref="CallToolResult{T}"/></term>
/// <description>
/// The <c>T</c> content is serialized to JSON and used as both a <see cref="TextContentBlock"/>
/// and as the <see cref="CallToolResult.StructuredContent"/>. The <see cref="CallToolResult{T}.IsError"/>
/// and <see cref="Result.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
/// The <c>T</c> type argument is also used to infer the <see cref="Tool.OutputSchema"/>.
/// </description>
/// </item>
/// <item>
/// <term>Other types</term>
/// <description>Serialized to JSON and returned as a single <see cref="ContentBlock"/> object with <see cref="ContentBlock.Type"/> set to "text".</description>
/// </item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ namespace ModelContextProtocol.Server;
/// <description>Returned directly without modification.</description>
/// </item>
/// <item>
/// <term><see cref="CallToolResult{T}"/></term>
/// <description>
/// The <c>T</c> content is serialized to JSON and used as both a <see cref="TextContentBlock"/>
/// and as the <see cref="CallToolResult.StructuredContent"/>. The <see cref="CallToolResult{T}.IsError"/>
/// and <see cref="Result.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
/// The <c>T</c> type argument is also used to infer the <see cref="Tool.OutputSchema"/>.
/// </description>
/// </item>
/// <item>
/// <term>Other types</term>
/// <description>Serialized to JSON and returned as a single <see cref="ContentBlock"/> object with <see cref="ContentBlock.Type"/> set to "text".</description>
/// </item>
Expand Down
26 changes: 18 additions & 8 deletions src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,26 @@ public sealed class McpServerToolCreateOptions
public bool? ReadOnly { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether the tool should report an output schema for structured content.
/// Gets or sets a JSON Schema object to use as the tool's output schema.
/// </summary>
/// <value>
/// The default is <see langword="false"/>.
/// </value>
/// <remarks>
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
/// and provide structured content in the <see cref="CallToolResult.StructuredContent"/> property.
/// <para>
/// When set, this schema is used directly as the <see cref="Tool.OutputSchema"/> instead of
/// inferring it from the method's return type. This is particularly useful when the method
/// returns <see cref="CallToolResult"/> directly (for example, to control
/// <see cref="CallToolResult.IsError"/>), but the tool should still advertise a meaningful
/// output schema describing the shape of <see cref="CallToolResult.StructuredContent"/>.
/// </para>
/// <para>
/// Setting this property to a non-<see langword="null"/> value will enable structured content
/// for the tool, causing the tool to populate both <see cref="Tool.OutputSchema"/> and
/// <see cref="CallToolResult.StructuredContent"/>.
/// </para>
/// <para>
/// The schema must be a valid JSON Schema object with the "type" property set to "object".
/// </para>
/// </remarks>
public bool UseStructuredContent { get; set; }
public JsonElement? OutputSchema { get; set; }

/// <summary>
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
Expand Down Expand Up @@ -208,7 +218,7 @@ internal McpServerToolCreateOptions Clone() =>
Idempotent = Idempotent,
OpenWorld = OpenWorld,
ReadOnly = ReadOnly,
UseStructuredContent = UseStructuredContent,
OutputSchema = OutputSchema,
SerializerOptions = SerializerOptions,
SchemaCreateOptions = SchemaCreateOptions,
Metadata = Metadata,
Expand Down
Loading