Skip to content

Commit f165bc4

Browse files
Copilotstephentoub
andcommitted
Return CallToolResult<T> from CallToolAsync<T>, use ToString and FirstOrDefault
- CallToolAsync<T> now returns CallToolResult<T> instead of T - No longer throws on IsError; caller can inspect IsError on the result - Use OfType<TextContentBlock>().FirstOrDefault() instead of indexing - Use ToString() instead of ToJsonString(serializerOptions) in ToCallToolResult - Updated tests accordingly Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 0a8bcea commit f165bc4

File tree

3 files changed

+43
-49
lines changed

3 files changed

+43
-49
lines changed

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

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

917917
/// <summary>
918-
/// Invokes a tool on the server and deserializes the result as a strongly-typed <typeparamref name="T"/>.
918+
/// Invokes a tool on the server and deserializes the result as a strongly-typed <see cref="CallToolResult{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>The deserialized result from the tool execution.</returns>
926+
/// <returns>A <see cref="CallToolResult{T}"/> containing the deserialized content, error state, and metadata.</returns>
927927
/// <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>
928+
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
931929
/// <exception cref="JsonException">The result content could not be deserialized as <typeparamref name="T"/>.</exception>
932930
/// <remarks>
933931
/// <para>
934932
/// This method calls the existing <see cref="CallToolAsync(string, IReadOnlyDictionary{string, object?}?, IProgress{ProgressNotificationValue}?, RequestOptions?, CancellationToken)"/>
935933
/// 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 last <see cref="TextContentBlock"/>
934+
/// as <typeparamref name="T"/>. Otherwise, if the result has text content, the text of the first <see cref="TextContentBlock"/>
937935
/// is deserialized as <typeparamref name="T"/>.
938936
/// </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>
943937
/// </remarks>
944-
public async ValueTask<T> CallToolAsync<T>(
938+
public async ValueTask<CallToolResult<T>> CallToolAsync<T>(
945939
string toolName,
946940
IReadOnlyDictionary<string, object?>? arguments = null,
947941
IProgress<ProgressNotificationValue>? progress = null,
@@ -950,29 +944,30 @@ public async ValueTask<T> CallToolAsync<T>(
950944
{
951945
CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false);
952946

953-
if (result.IsError is true)
954-
{
955-
string errorMessage = result.Content.Count > 0 && result.Content[^1] 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));
947+
T? content = default;
963948

964-
// Prefer StructuredContent if available, otherwise fall back to text content
965-
if (result.StructuredContent is { } structuredContent)
949+
if (result.IsError is not true)
966950
{
967-
return JsonSerializer.Deserialize(structuredContent, typeInfo)!;
968-
}
951+
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
952+
JsonTypeInfo<T> typeInfo = (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T));
969953

970-
if (result.Content.Count > 0 && result.Content[^1] is TextContentBlock textContent)
971-
{
972-
return JsonSerializer.Deserialize(textContent.Text, typeInfo)!;
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+
}
973963
}
974964

975-
throw new McpException("The tool call did not return any content that could be deserialized.");
965+
return new()
966+
{
967+
Content = content,
968+
IsError = result.IsError,
969+
Meta = result.Meta,
970+
};
976971
}
977972

978973
/// <summary>

src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions seria
6464

6565
return new()
6666
{
67-
Content = [new TextContentBlock { Text = structuredContent?.ToJsonString(serializerOptions) ?? "null" }],
67+
Content = [new TextContentBlock { Text = structuredContent?.ToString() ?? "null" }],
6868
StructuredContent = structuredContent,
6969
IsError = IsError,
7070
Meta = Meta,

tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs

Lines changed: 18 additions & 19 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);
45-
Assert.Equal("Alice", result.Name);
46-
Assert.Equal(30, result.Age);
44+
Assert.NotNull(result.Content);
45+
Assert.Equal("Alice", result.Content.Name);
46+
Assert.Equal(30, result.Content.Age);
4747
}
4848

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

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

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

6968
[Fact]
@@ -83,9 +82,9 @@ public async Task CallToolAsyncOfT_FallsBackToTextContent()
8382
options: new() { JsonSerializerOptions = serOpts },
8483
cancellationToken: TestContext.Current.CancellationToken);
8584

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

9190
[Fact]
@@ -129,9 +128,9 @@ public async Task CallToolAsyncOfT_WithAsyncTool_Works()
129128
options: new() { JsonSerializerOptions = serOpts },
130129
cancellationToken: TestContext.Current.CancellationToken);
131130

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

137136
[Fact]
@@ -154,9 +153,9 @@ public async Task CallToolAsyncOfT_WithArguments_Works()
154153
options: new() { JsonSerializerOptions = serOpts },
155154
cancellationToken: TestContext.Current.CancellationToken);
156155

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

162161
private class PersonData

0 commit comments

Comments
 (0)