Skip to content

Commit dcfff8e

Browse files
Copilotstephentoub
andcommitted
Add CallToolResult<T> and CallToolAsync<T>, remove OutputSchemaType from attribute
- Remove OutputSchemaType from McpServerToolAttribute (keep OutputSchema on options) - Add sealed CallToolResult<T> class as a peer of CallToolResult with T Content - Add ICallToolResultTyped internal interface for generic pattern matching - Update AIFunctionMcpServerTool to recognize CallToolResult<T> return type: - DeriveOptions detects CallToolResult<T> and generates OutputSchema from T - InvokeAsync handles ICallToolResultTyped to convert to CallToolResult - Add CallToolAsync<T> to McpClient that deserializes StructuredContent/Content as T - Update XML doc comments on McpServerTool and McpServerToolAttribute Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 1d9f40c commit dcfff8e

5 files changed

Lines changed: 205 additions & 30 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Diagnostics.CodeAnalysis;
55
using System.Text.Json;
66
using System.Text.Json.Nodes;
7+
using System.Text.Json.Serialization.Metadata;
78

89
namespace ModelContextProtocol.Client;
910

@@ -913,6 +914,67 @@ public ValueTask<CallToolResult> CallToolAsync(
913914
cancellationToken: cancellationToken);
914915
}
915916

917+
/// <summary>
918+
/// Invokes a tool on the server and deserializes the result as a strongly-typed <typeparamref name="T"/>.
919+
/// </summary>
920+
/// <typeparam name="T">The type to deserialize the tool's structured content or text content into.</typeparam>
921+
/// <param name="toolName">The name of the tool to call on the server.</param>
922+
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
923+
/// <param name="progress">An optional progress reporter for server notifications.</param>
924+
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
925+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
926+
/// <returns>The deserialized result from the tool execution.</returns>
927+
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
928+
/// <exception cref="McpException">
929+
/// The request failed, the server returned an error response, or <see cref="CallToolResult.IsError"/> was <see langword="true"/>.
930+
/// </exception>
931+
/// <exception cref="JsonException">The result content could not be deserialized as <typeparamref name="T"/>.</exception>
932+
/// <remarks>
933+
/// <para>
934+
/// This method calls the existing <see cref="CallToolAsync(string, IReadOnlyDictionary{string, object?}?, IProgress{ProgressNotificationValue}?, RequestOptions?, CancellationToken)"/>
935+
/// and then deserializes the result. If the result has <see cref="CallToolResult.StructuredContent"/>, that is deserialized
936+
/// as <typeparamref name="T"/>. Otherwise, if the result has text content, the text of the first <see cref="TextContentBlock"/>
937+
/// is deserialized as <typeparamref name="T"/>.
938+
/// </para>
939+
/// <para>
940+
/// If <see cref="CallToolResult.IsError"/> is <see langword="true"/>, an <see cref="McpException"/> is thrown
941+
/// with the error content details.
942+
/// </para>
943+
/// </remarks>
944+
public async ValueTask<T> CallToolAsync<T>(
945+
string toolName,
946+
IReadOnlyDictionary<string, object?>? arguments = null,
947+
IProgress<ProgressNotificationValue>? progress = null,
948+
RequestOptions? options = null,
949+
CancellationToken cancellationToken = default)
950+
{
951+
CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false);
952+
953+
if (result.IsError is true)
954+
{
955+
string errorMessage = result.Content.Count > 0 && result.Content[0] is TextContentBlock textBlock
956+
? textBlock.Text
957+
: "The tool call returned an error.";
958+
throw new McpException(errorMessage);
959+
}
960+
961+
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
962+
JsonTypeInfo<T> typeInfo = (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T));
963+
964+
// Prefer StructuredContent if available, otherwise fall back to text content
965+
if (result.StructuredContent is { } structuredContent)
966+
{
967+
return JsonSerializer.Deserialize(structuredContent, typeInfo)!;
968+
}
969+
970+
if (result.Content.Count > 0 && result.Content[0] is TextContentBlock textContent)
971+
{
972+
return JsonSerializer.Deserialize(textContent.Text, typeInfo)!;
973+
}
974+
975+
throw new McpException("The tool call did not return any content that could be deserialized.");
976+
}
977+
916978
/// <summary>
917979
/// Invokes a tool on the server as a task for long-running operations.
918980
/// </summary>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Nodes;
3+
4+
namespace ModelContextProtocol.Protocol;
5+
6+
/// <summary>
7+
/// Represents a strongly-typed result of a <see cref="RequestMethods.ToolsCall"/> request.
8+
/// </summary>
9+
/// <typeparam name="T">
10+
/// The type of the structured content returned by the tool. This type is used to infer the
11+
/// <see cref="Tool.OutputSchema"/> advertised by the tool.
12+
/// </typeparam>
13+
/// <remarks>
14+
/// <para>
15+
/// <see cref="CallToolResult{T}"/> provides a way to return strongly-typed structured content from a tool
16+
/// while still providing access to <see cref="Result.Meta"/> and <see cref="IsError"/>. When a tool method
17+
/// returns <see cref="CallToolResult{T}"/>, the SDK uses <typeparamref name="T"/> to infer the output schema
18+
/// and serializes <see cref="Content"/> as both the text content and structured content of the response.
19+
/// </para>
20+
/// <para>
21+
/// This type is a peer of <see cref="CallToolResult"/>, not a subclass. Use <see cref="CallToolResult"/> when
22+
/// you need full control over individual content blocks, and <see cref="CallToolResult{T}"/> when you want
23+
/// the SDK to handle serialization of a strongly-typed result.
24+
/// </para>
25+
/// </remarks>
26+
public sealed class CallToolResult<T> : ICallToolResultTyped
27+
{
28+
/// <summary>
29+
/// Gets or sets the typed content returned by the tool.
30+
/// </summary>
31+
public T? Content { get; set; }
32+
33+
/// <summary>
34+
/// Gets or sets a value that indicates whether the tool call was unsuccessful.
35+
/// </summary>
36+
/// <value>
37+
/// <see langword="true"/> to signify that the tool execution failed; <see langword="false"/> if it was successful.
38+
/// </value>
39+
/// <remarks>
40+
/// <para>
41+
/// Tool execution errors (including input validation errors, API failures, and business logic errors)
42+
/// are reported with this property set to <see langword="true"/> and details in the <see cref="Content"/>
43+
/// property, rather than as protocol-level errors.
44+
/// </para>
45+
/// <para>
46+
/// This design allows language models to receive detailed error feedback and potentially self-correct
47+
/// in subsequent requests.
48+
/// </para>
49+
/// </remarks>
50+
public bool? IsError { get; set; }
51+
52+
/// <summary>
53+
/// Gets or sets metadata reserved by MCP for protocol-level metadata.
54+
/// </summary>
55+
/// <remarks>
56+
/// Implementations must not make assumptions about its contents.
57+
/// </remarks>
58+
public JsonObject? Meta { get; set; }
59+
60+
/// <inheritdoc/>
61+
CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions)
62+
{
63+
var typeInfo = serializerOptions.GetTypeInfo(typeof(T));
64+
65+
string json = JsonSerializer.Serialize(Content, typeInfo);
66+
JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, typeInfo);
67+
68+
return new()
69+
{
70+
Content = [new TextContentBlock { Text = json }],
71+
StructuredContent = structuredContent,
72+
IsError = IsError,
73+
Meta = Meta,
74+
};
75+
}
76+
}
77+
78+
/// <summary>
79+
/// Internal interface for converting strongly-typed tool results to <see cref="CallToolResult"/>.
80+
/// </summary>
81+
internal interface ICallToolResultTyped
82+
{
83+
/// <summary>
84+
/// Converts the strongly-typed result to a <see cref="CallToolResult"/>.
85+
/// </summary>
86+
CallToolResult ToCallToolResult(JsonSerializerOptions serializerOptions);
87+
}

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,14 +206,6 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
206206

207207
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
208208

209-
if (toolAttr.OutputSchemaType is { } outputSchemaType)
210-
{
211-
newOptions.UseStructuredContent = true;
212-
newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType,
213-
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
214-
inferenceOptions: newOptions.SchemaCreateOptions);
215-
}
216-
217209
if (toolAttr._taskSupport is { } taskSupport)
218210
{
219211
newOptions.Execution ??= new ToolExecution();
@@ -235,6 +227,15 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
235227
newOptions.UseStructuredContent = true;
236228
}
237229

230+
// If the method returns CallToolResult<T>, automatically use T for the output schema
231+
if (GetCallToolResultContentType(method) is { } contentType)
232+
{
233+
newOptions.UseStructuredContent = true;
234+
newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(contentType,
235+
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
236+
inferenceOptions: newOptions.SchemaCreateOptions);
237+
}
238+
238239
return newOptions;
239240
}
240241

@@ -319,6 +320,8 @@ public override async ValueTask<CallToolResult> InvokeAsync(
319320

320321
CallToolResult callToolResponse => callToolResponse,
321322

323+
ICallToolResultTyped typedResult => typedResult.ToCallToolResult(AIFunction.JsonSerializerOptions),
324+
322325
_ => new()
323326
{
324327
Content = [new TextContentBlock { Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))) }],
@@ -375,6 +378,33 @@ private static bool IsAsyncMethod(MethodInfo method)
375378
return false;
376379
}
377380

381+
/// <summary>
382+
/// If the method's return type is <see cref="CallToolResult{T}"/> (possibly wrapped in <see cref="Task{TResult}"/>
383+
/// or <see cref="ValueTask{TResult}"/>), returns the <c>T</c> type argument. Otherwise, returns <see langword="null"/>.
384+
/// </summary>
385+
private static Type? GetCallToolResultContentType(MethodInfo method)
386+
{
387+
Type t = method.ReturnType;
388+
389+
// Unwrap Task<T> or ValueTask<T>
390+
if (t.IsGenericType)
391+
{
392+
Type genericDef = t.GetGenericTypeDefinition();
393+
if (genericDef == typeof(Task<>) || genericDef == typeof(ValueTask<>))
394+
{
395+
t = t.GetGenericArguments()[0];
396+
}
397+
}
398+
399+
// Check if the unwrapped type is CallToolResult<T>
400+
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(CallToolResult<>))
401+
{
402+
return t.GetGenericArguments()[0];
403+
}
404+
405+
return null;
406+
}
407+
378408
/// <summary>Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item.</summary>
379409
internal static IReadOnlyList<object> CreateMetadata(MethodInfo method)
380410
{

src/ModelContextProtocol.Core/Server/McpServerTool.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ namespace ModelContextProtocol.Server;
122122
/// <description>Returned directly without modification.</description>
123123
/// </item>
124124
/// <item>
125+
/// <term><see cref="CallToolResult{T}"/></term>
126+
/// <description>
127+
/// The <c>T</c> content is serialized to JSON and used as both a <see cref="TextContentBlock"/>
128+
/// and as the <see cref="CallToolResult.StructuredContent"/>. The <see cref="CallToolResult{T}.IsError"/>
129+
/// and <see cref="CallToolResult{T}.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
130+
/// The <c>T</c> type argument is also used to infer the <see cref="Tool.OutputSchema"/>.
131+
/// </description>
132+
/// </item>
133+
/// <item>
125134
/// <term>Other types</term>
126135
/// <description>Serialized to JSON and returned as a single <see cref="ContentBlock"/> object with <see cref="ContentBlock.Type"/> set to "text".</description>
127136
/// </item>

src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ namespace ModelContextProtocol.Server;
118118
/// <description>Returned directly without modification.</description>
119119
/// </item>
120120
/// <item>
121+
/// <term><see cref="CallToolResult{T}"/></term>
122+
/// <description>
123+
/// The <c>T</c> content is serialized to JSON and used as both a <see cref="TextContentBlock"/>
124+
/// and as the <see cref="CallToolResult.StructuredContent"/>. The <see cref="CallToolResult{T}.IsError"/>
125+
/// and <see cref="CallToolResult{T}.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
126+
/// The <c>T</c> type argument is also used to infer the <see cref="Tool.OutputSchema"/>.
127+
/// </description>
128+
/// </item>
129+
/// <item>
121130
/// <term>Other types</term>
122131
/// <description>Serialized to JSON and returned as a single <see cref="ContentBlock"/> object with <see cref="ContentBlock.Type"/> set to "text".</description>
123132
/// </item>
@@ -240,33 +249,11 @@ public bool ReadOnly
240249
/// The default is <see langword="false"/>.
241250
/// </value>
242251
/// <remarks>
243-
/// <para>
244252
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
245253
/// and provide structured content in the <see cref="CallToolResult.StructuredContent"/> property.
246-
/// </para>
247-
/// <para>
248-
/// Setting <see cref="OutputSchemaType"/> to a non-<see langword="null"/> value will automatically enable structured content.
249-
/// </para>
250254
/// </remarks>
251255
public bool UseStructuredContent { get; set; }
252256

253-
/// <summary>
254-
/// Gets or sets the type to use for generating the tool's output schema.
255-
/// </summary>
256-
/// <remarks>
257-
/// <para>
258-
/// When set, the SDK generates the <see cref="Tool.OutputSchema"/> from this type instead of
259-
/// inferring it from the method's return type. This is particularly useful when the method
260-
/// returns <see cref="CallToolResult"/> directly (for example, to control
261-
/// <see cref="CallToolResult.IsError"/>), but the tool should still advertise a meaningful
262-
/// output schema describing the shape of <see cref="CallToolResult.StructuredContent"/>.
263-
/// </para>
264-
/// <para>
265-
/// Setting this property to a non-<see langword="null"/> value will automatically enable <see cref="UseStructuredContent"/>.
266-
/// </para>
267-
/// </remarks>
268-
public Type? OutputSchemaType { get; set; }
269-
270257
/// <summary>
271258
/// Gets or sets the source URI for the tool's icon.
272259
/// </summary>

0 commit comments

Comments
 (0)