Skip to content

Commit 0815ca9

Browse files
Copilotstephentoub
andcommitted
CallToolResult<T> derives from Result, CallToolAsync<T> returns T and throws on IsError, consolidate schema handling
- CallToolResult<T> now derives from Result (inherits Meta), removed duplicate Meta property - CallToolAsync<T> returns T? instead of CallToolResult<T>, throws McpException on IsError - Consolidated schema handling in DeriveOptions: unified UseStructuredContent and CallToolResult<T> auto-detection into a single block - Updated XML docs for cref changes (Result.Meta) Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent f165bc4 commit 0815ca9

File tree

6 files changed

+56
-62
lines changed

6 files changed

+56
-62
lines changed

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -915,17 +915,17 @@ public ValueTask<CallToolResult> CallToolAsync(
915915
}
916916

917917
/// <summary>
918-
/// Invokes a tool on the server and deserializes the result as a strongly-typed <see cref="CallToolResult{T}"/>.
918+
/// Invokes a tool on the server and deserializes the result as <typeparamref name="T"/>.
919919
/// </summary>
920920
/// <typeparam name="T">The type to deserialize the tool's structured content or text content into.</typeparam>
921921
/// <param name="toolName">The name of the tool to call on the server.</param>
922922
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
923923
/// <param name="progress">An optional progress reporter for server notifications.</param>
924924
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
925925
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
926-
/// <returns>A <see cref="CallToolResult{T}"/> containing the deserialized content, error state, and metadata.</returns>
926+
/// <returns>The deserialized content of the tool result.</returns>
927927
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
928-
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
928+
/// <exception cref="McpException">The request failed, the server returned an error response, or <see cref="CallToolResult.IsError"/> is <see langword="true"/>.</exception>
929929
/// <exception cref="JsonException">The result content could not be deserialized as <typeparamref name="T"/>.</exception>
930930
/// <remarks>
931931
/// <para>
@@ -934,8 +934,12 @@ public ValueTask<CallToolResult> CallToolAsync(
934934
/// as <typeparamref name="T"/>. Otherwise, if the result has text content, the text of the first <see cref="TextContentBlock"/>
935935
/// is deserialized as <typeparamref name="T"/>.
936936
/// </para>
937+
/// <para>
938+
/// If <see cref="CallToolResult.IsError"/> is <see langword="true"/>, an <see cref="McpException"/> is thrown. To inspect
939+
/// error details without an exception, use the non-generic <see cref="CallToolAsync(string, IReadOnlyDictionary{string, object?}?, IProgress{ProgressNotificationValue}?, RequestOptions?, CancellationToken)"/> overload instead.
940+
/// </para>
937941
/// </remarks>
938-
public async ValueTask<CallToolResult<T>> CallToolAsync<T>(
942+
public async ValueTask<T?> CallToolAsync<T>(
939943
string toolName,
940944
IReadOnlyDictionary<string, object?>? arguments = null,
941945
IProgress<ProgressNotificationValue>? progress = null,
@@ -944,30 +948,27 @@ public async ValueTask<CallToolResult<T>> CallToolAsync<T>(
944948
{
945949
CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false);
946950

947-
T? content = default;
948-
949-
if (result.IsError is not true)
951+
if (result.IsError is true)
950952
{
951-
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
952-
JsonTypeInfo<T> typeInfo = (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T));
953+
string errorMessage = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "Tool call failed.";
954+
throw new McpException(errorMessage);
955+
}
953956

954-
// Prefer StructuredContent if available, otherwise fall back to text content
955-
if (result.StructuredContent is { } structuredContent)
956-
{
957-
content = JsonSerializer.Deserialize(structuredContent, typeInfo);
958-
}
959-
else if (result.Content.OfType<TextContentBlock>().FirstOrDefault() is { } textContent)
960-
{
961-
content = JsonSerializer.Deserialize(textContent.Text, typeInfo);
962-
}
957+
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
958+
JsonTypeInfo<T> typeInfo = (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T));
959+
960+
// Prefer StructuredContent if available, otherwise fall back to text content
961+
if (result.StructuredContent is { } structuredContent)
962+
{
963+
return JsonSerializer.Deserialize(structuredContent, typeInfo);
963964
}
964965

965-
return new()
966+
if (result.Content.OfType<TextContentBlock>().FirstOrDefault() is { } textContent)
966967
{
967-
Content = content,
968-
IsError = result.IsError,
969-
Meta = result.Meta,
970-
};
968+
return JsonSerializer.Deserialize(textContent.Text, typeInfo);
969+
}
970+
971+
return default;
971972
}
972973

973974
/// <summary>

src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ namespace ModelContextProtocol.Protocol;
2323
/// the SDK to handle serialization of a strongly-typed result.
2424
/// </para>
2525
/// </remarks>
26-
public sealed class CallToolResult<T> : ICallToolResultTyped
26+
public sealed class CallToolResult<T> : Result, ICallToolResultTyped
2727
{
2828
/// <summary>
2929
/// Gets or sets the typed content returned by the tool.
@@ -49,14 +49,6 @@ public sealed class CallToolResult<T> : ICallToolResultTyped
4949
/// </remarks>
5050
public bool? IsError { get; set; }
5151

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-
6052
/// <inheritdoc/>
6153
CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions)
6254
{

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
174174
{
175175
McpServerToolCreateOptions newOptions = options?.Clone() ?? new();
176176

177+
bool useStructuredContent = false;
177178
if (method.GetCustomAttribute<McpServerToolAttribute>() is { } toolAttr)
178179
{
179180
newOptions.Name ??= toolAttr.Name;
@@ -210,15 +211,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
210211
newOptions.Execution.TaskSupport ??= taskSupport;
211212
}
212213

213-
// When the attribute enables structured content, generate the output schema from the return type.
214-
// If the return type is CallToolResult<T>, use T rather than the full return type.
215-
if (toolAttr.UseStructuredContent)
216-
{
217-
Type outputType = GetCallToolResultContentType(method.ReturnType) ?? method.ReturnType;
218-
newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputType,
219-
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
220-
inferenceOptions: newOptions.SchemaCreateOptions);
221-
}
214+
useStructuredContent = toolAttr.UseStructuredContent;
222215
}
223216

224217
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
@@ -229,10 +222,18 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
229222
// Set metadata if not already provided
230223
newOptions.Metadata ??= CreateMetadata(method);
231224

232-
// If the method returns CallToolResult<T>, automatically use T for the output schema
233-
if (GetCallToolResultContentType(method.ReturnType) is { } contentType)
225+
// Generate the output schema from the return type if needed.
226+
// UseStructuredContent on the attribute uses T from CallToolResult<T> or the return type directly.
227+
// CallToolResult<T> return types automatically infer the schema from T even without UseStructuredContent.
228+
Type? outputSchemaType = GetCallToolResultContentType(method.ReturnType);
229+
if (outputSchemaType is null && useStructuredContent)
230+
{
231+
outputSchemaType = method.ReturnType;
232+
}
233+
234+
if (outputSchemaType is not null)
234235
{
235-
newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(contentType,
236+
newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType,
236237
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
237238
inferenceOptions: newOptions.SchemaCreateOptions);
238239
}

src/ModelContextProtocol.Core/Server/McpServerTool.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ namespace ModelContextProtocol.Server;
146146
/// <description>
147147
/// The <c>T</c> content is serialized to JSON and used as both a <see cref="TextContentBlock"/>
148148
/// and as the <see cref="CallToolResult.StructuredContent"/>. The <see cref="CallToolResult{T}.IsError"/>
149-
/// and <see cref="CallToolResult{T}.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
149+
/// and <see cref="Result.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
150150
/// The <c>T</c> type argument is also used to infer the <see cref="Tool.OutputSchema"/>.
151151
/// </description>
152152
/// </item>

src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ namespace ModelContextProtocol.Server;
142142
/// <description>
143143
/// The <c>T</c> content is serialized to JSON and used as both a <see cref="TextContentBlock"/>
144144
/// and as the <see cref="CallToolResult.StructuredContent"/>. The <see cref="CallToolResult{T}.IsError"/>
145-
/// and <see cref="CallToolResult{T}.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
145+
/// and <see cref="Result.Meta"/> properties are propagated to the resulting <see cref="CallToolResult"/>.
146146
/// The <c>T</c> type argument is also used to infer the <see cref="Tool.OutputSchema"/>.
147147
/// </description>
148148
/// </item>

tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ public async Task CallToolAsyncOfT_DeserializesStructuredContent()
4141
options: new() { JsonSerializerOptions = serOpts },
4242
cancellationToken: TestContext.Current.CancellationToken);
4343

44-
Assert.NotNull(result.Content);
45-
Assert.Equal("Alice", result.Content.Name);
46-
Assert.Equal(30, result.Content.Age);
44+
Assert.NotNull(result);
45+
Assert.Equal("Alice", result.Name);
46+
Assert.Equal(30, result.Age);
4747
}
4848

4949
[Fact]
50-
public async Task CallToolAsyncOfT_PropagatesIsError()
50+
public async Task CallToolAsyncOfT_ThrowsOnIsError()
5151
{
5252
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
5353
_toolCollection.Add(McpServerTool.Create(
@@ -57,12 +57,12 @@ public async Task CallToolAsyncOfT_PropagatesIsError()
5757
StartServer();
5858
var client = await CreateMcpClientForServer();
5959

60-
var result = await client.CallToolAsync<string>(
60+
var ex = await Assert.ThrowsAsync<McpException>(() => client.CallToolAsync<string>(
6161
"error_tool",
6262
options: new() { JsonSerializerOptions = serOpts },
63-
cancellationToken: TestContext.Current.CancellationToken);
63+
cancellationToken: TestContext.Current.CancellationToken).AsTask());
6464

65-
Assert.True(result.IsError);
65+
Assert.Contains("something went wrong", ex.Message);
6666
}
6767

6868
[Fact]
@@ -82,9 +82,9 @@ public async Task CallToolAsyncOfT_FallsBackToTextContent()
8282
options: new() { JsonSerializerOptions = serOpts },
8383
cancellationToken: TestContext.Current.CancellationToken);
8484

85-
Assert.NotNull(result.Content);
86-
Assert.Equal("Bob", result.Content.Name);
87-
Assert.Equal(25, result.Content.Age);
85+
Assert.NotNull(result);
86+
Assert.Equal("Bob", result.Name);
87+
Assert.Equal(25, result.Age);
8888
}
8989

9090
[Fact]
@@ -128,9 +128,9 @@ public async Task CallToolAsyncOfT_WithAsyncTool_Works()
128128
options: new() { JsonSerializerOptions = serOpts },
129129
cancellationToken: TestContext.Current.CancellationToken);
130130

131-
Assert.NotNull(result.Content);
132-
Assert.Equal("Charlie", result.Content.Name);
133-
Assert.Equal(35, result.Content.Age);
131+
Assert.NotNull(result);
132+
Assert.Equal("Charlie", result.Name);
133+
Assert.Equal(35, result.Age);
134134
}
135135

136136
[Fact]
@@ -153,9 +153,9 @@ public async Task CallToolAsyncOfT_WithArguments_Works()
153153
options: new() { JsonSerializerOptions = serOpts },
154154
cancellationToken: TestContext.Current.CancellationToken);
155155

156-
Assert.NotNull(result.Content);
157-
Assert.Equal("Diana", result.Content.Name);
158-
Assert.Equal(28, result.Content.Age);
156+
Assert.NotNull(result);
157+
Assert.Equal("Diana", result.Name);
158+
Assert.Equal(28, result.Age);
159159
}
160160

161161
private class PersonData

0 commit comments

Comments
 (0)