diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index e58857dab..04f329437 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -392,7 +392,17 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
}
}
- return await handler(request, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var result = await handler(request, cancellationToken).ConfigureAwait(false);
+ ReadResourceCompleted(request.Params?.Uri ?? string.Empty);
+ return result;
+ }
+ catch (Exception e)
+ {
+ ReadResourceError(request.Params?.Uri ?? string.Empty, e);
+ throw;
+ }
});
subscribeHandler = BuildFilterPipeline(subscribeHandler, options.Filters.SubscribeToResourcesFilters);
unsubscribeHandler = BuildFilterPipeline(unsubscribeHandler, options.Filters.UnsubscribeFromResourcesFilters);
@@ -487,7 +497,7 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals
listPromptsHandler = BuildFilterPipeline(listPromptsHandler, options.Filters.ListPromptsFilters);
getPromptHandler = BuildFilterPipeline(getPromptHandler, options.Filters.GetPromptFilters, handler =>
- (request, cancellationToken) =>
+ async (request, cancellationToken) =>
{
// Initial handler that sets MatchedPrimitive
if (request.Params?.Name is { } promptName && prompts is not null &&
@@ -496,7 +506,17 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals
request.MatchedPrimitive = prompt;
}
- return handler(request, cancellationToken);
+ try
+ {
+ var result = await handler(request, cancellationToken).ConfigureAwait(false);
+ GetPromptCompleted(request.Params?.Name ?? string.Empty);
+ return result;
+ }
+ catch (Exception e)
+ {
+ GetPromptError(request.Params?.Name ?? string.Empty, e);
+ throw;
+ }
});
ServerCapabilities.Prompts.ListChanged = listChanged;
@@ -610,20 +630,35 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
try
{
- return await handler(request, cancellationToken).ConfigureAwait(false);
+ var result = await handler(request, cancellationToken).ConfigureAwait(false);
+
+ // Don't log here for task-augmented calls; logging happens asynchronously
+ // in ExecuteToolAsTaskAsync when the tool actually completes.
+ if (result.Task is null)
+ {
+ ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
+ }
+
+ return result;
}
- catch (Exception e) when (e is not OperationCanceledException and not McpProtocolException)
+ catch (Exception e)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
- string errorMessage = e is McpException ?
- $"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
- $"An error occurred invoking '{request.Params?.Name}'.";
+ if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException)
+ {
+ throw;
+ }
return new()
{
IsError = true,
- Content = [new TextContentBlock { Text = errorMessage }],
+ Content = [new TextContentBlock
+ {
+ Text = e is McpException ?
+ $"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
+ $"An error occurred invoking '{request.Params?.Name}'.",
+ }],
};
}
});
@@ -944,6 +979,21 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
[LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")]
private partial void ToolCallError(string toolName, Exception exception);
+ [LoggerMessage(Level = LogLevel.Information, Message = "\"{ToolName}\" completed. IsError = {IsError}.")]
+ private partial void ToolCallCompleted(string toolName, bool isError);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "GetPrompt \"{PromptName}\" threw an unhandled exception.")]
+ private partial void GetPromptError(string promptName, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "GetPrompt \"{PromptName}\" completed.")]
+ private partial void GetPromptCompleted(string promptName);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "ReadResource \"{ResourceUri}\" threw an unhandled exception.")]
+ private partial void ReadResourceError(string resourceUri, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")]
+ private partial void ReadResourceCompleted(string resourceUri);
+
///
/// Executes a tool call as a task and returns a CallToolTaskResult immediately.
///
@@ -1004,6 +1054,7 @@ private async ValueTask ExecuteToolAsTaskAsync(
// Invoke the tool with task-specific cancellation token
var result = await tool.InvokeAsync(request, taskCancellationToken).ConfigureAwait(false);
+ ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
// Determine final status based on whether there was an error
var finalStatus = result.IsError is true ? McpTaskStatus.Failed : McpTaskStatus.Completed;
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs
index 3b9137f61..69405e16c 100644
--- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -101,7 +102,7 @@ public async Task Can_List_And_Call_Registered_Prompts()
await using McpClient client = await CreateMcpClientForServer();
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, prompts.Count);
+ Assert.Equal(8, prompts.Count);
var prompt = prompts.First(t => t.Name == "returns_chat_messages");
Assert.Equal("Returns chat messages", prompt.Description);
@@ -130,7 +131,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes()
await using McpClient client = await CreateMcpClientForServer();
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, prompts.Count);
+ Assert.Equal(8, prompts.Count);
Channel listChanged = Channel.CreateUnbounded();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -151,7 +152,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes()
await notificationRead;
prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(7, prompts.Count);
+ Assert.Equal(9, prompts.Count);
Assert.Contains(prompts, t => t.Name == "NewPrompt");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -161,7 +162,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes()
}
prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, prompts.Count);
+ Assert.Equal(8, prompts.Count);
Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt");
}
@@ -195,6 +196,75 @@ await Assert.ThrowsAsync(async () => await client.GetPromp
cancellationToken: TestContext.Current.CancellationToken));
}
+ [Fact]
+ public async Task Logs_Prompt_Name_On_Successful_Call()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.GetPromptAsync(
+ "returns_chat_messages",
+ new Dictionary { ["message"] = "hello" },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "GetPrompt \"returns_chat_messages\" completed.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Prompt_Name_When_Prompt_Throws()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.GetPromptAsync(
+ "throws_exception",
+ new Dictionary { ["message"] = "test" },
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("GetPrompt \"throws_exception\" threw an unhandled exception.", errorLog.Message);
+ Assert.IsType(errorLog.Exception);
+ }
+
+ [Fact]
+ public async Task Logs_Prompt_Error_When_Prompt_Throws_OperationCanceledException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.GetPromptAsync(
+ "throws_operation_canceled_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "GetPrompt \"throws_operation_canceled_exception\" threw an unhandled exception." &&
+ m.Exception is OperationCanceledException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
+ [Fact]
+ public async Task Logs_Prompt_Error_When_Prompt_Throws_McpProtocolException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.GetPromptAsync(
+ "throws_mcp_protocol_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "GetPrompt \"throws_mcp_protocol_exception\" threw an unhandled exception." &&
+ m.Exception is McpProtocolException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
[Fact]
public async Task Throws_Exception_On_Unknown_Prompt()
{
@@ -335,6 +405,14 @@ public static ChatMessage[] ReturnsChatMessages([Description("The first paramete
public static ChatMessage[] ThrowsException([Description("The first parameter")] string message) =>
throw new FormatException("uh oh");
+ [McpServerPrompt, Description("Throws OperationCanceledException")]
+ public static ChatMessage[] ThrowsOperationCanceledException() =>
+ throw new OperationCanceledException("Prompt was canceled");
+
+ [McpServerPrompt, Description("Throws McpProtocolException")]
+ public static ChatMessage[] ThrowsMcpProtocolException() =>
+ throw new McpProtocolException("Prompt protocol error", McpErrorCode.InvalidParams);
+
[McpServerPrompt(Title = "This is a title", IconSource = "https://example.com/prompt-icon.svg"), Description("Returns chat messages")]
public string ReturnsString([Description("The first parameter")] string message) =>
$"The prompt is: {message}. The id is {id}.";
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
index 5d3f56233..545384a7c 100644
--- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -130,7 +131,7 @@ public async Task Can_List_And_Call_Registered_Resources()
Assert.NotNull(client.ServerCapabilities.Resources);
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(5, resources.Count);
+ Assert.Equal(7, resources.Count);
var resource = resources.First(t => t.Name == "some_neat_direct_resource");
Assert.Equal("Some neat direct resource", resource.Description);
@@ -164,7 +165,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes()
await using McpClient client = await CreateMcpClientForServer();
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(5, resources.Count);
+ Assert.Equal(7, resources.Count);
Channel listChanged = Channel.CreateUnbounded();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -185,7 +186,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes()
await notificationRead;
resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(6, resources.Count);
+ Assert.Equal(8, resources.Count);
Assert.Contains(resources, t => t.Name == "NewResource");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -195,7 +196,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes()
}
resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(5, resources.Count);
+ Assert.Equal(7, resources.Count);
Assert.DoesNotContain(resources, t => t.Name == "NewResource");
}
@@ -239,6 +240,73 @@ await Assert.ThrowsAsync(async () => await client.ReadReso
cancellationToken: TestContext.Current.CancellationToken));
}
+ [Fact]
+ public async Task Logs_Resource_Uri_On_Successful_Read()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ReadResourceAsync(
+ "resource://mcp/some_neat_direct_resource",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "ReadResource \"resource://mcp/some_neat_direct_resource\" completed.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Resource_Uri_When_Resource_Throws()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ "resource://mcp/throws_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("ReadResource \"resource://mcp/throws_exception\" threw an unhandled exception.", errorLog.Message);
+ Assert.IsType(errorLog.Exception);
+ }
+
+ [Fact]
+ public async Task Logs_Resource_Error_When_Resource_Throws_OperationCanceledException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ "resource://mcp/throws_operation_canceled_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "ReadResource \"resource://mcp/throws_operation_canceled_exception\" threw an unhandled exception." &&
+ m.Exception is OperationCanceledException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
+ [Fact]
+ public async Task Logs_Resource_Error_When_Resource_Throws_McpProtocolException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ "resource://mcp/throws_mcp_protocol_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "ReadResource \"resource://mcp/throws_mcp_protocol_exception\" threw an unhandled exception." &&
+ m.Exception is McpProtocolException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
[Fact]
public async Task Throws_Exception_On_Unknown_Resource()
{
@@ -361,6 +429,12 @@ public sealed class SimpleResources
[McpServerResource]
public static string ThrowsException() => throw new InvalidOperationException("uh oh");
+
+ [McpServerResource]
+ public static string ThrowsOperationCanceledException() => throw new OperationCanceledException("Resource was canceled");
+
+ [McpServerResource]
+ public static string ThrowsMcpProtocolException() => throw new McpProtocolException("Resource protocol error", McpErrorCode.InvalidParams);
}
[McpServerResourceType]
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs
index a0fb3cbe4..518b70f00 100644
--- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs
@@ -127,7 +127,7 @@ public async Task Can_List_Registered_Tools()
await using McpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
McpClientTool echoTool = tools.First(t => t.Name == "echo");
Assert.Equal("Echoes the input back to the client.", echoTool.Description);
@@ -165,7 +165,7 @@ public async Task Can_Create_Multiple_Servers_From_Options_And_List_Registered_T
cancellationToken: TestContext.Current.CancellationToken))
{
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
McpClientTool echoTool = tools.First(t => t.Name == "echo");
Assert.Equal("Echoes the input back to the client.", echoTool.Description);
@@ -191,7 +191,7 @@ public async Task Can_Be_Notified_Of_Tool_Changes()
await using McpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
Channel listChanged = Channel.CreateUnbounded();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -212,7 +212,7 @@ public async Task Can_Be_Notified_Of_Tool_Changes()
await notificationRead;
tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(17, tools.Count);
+ Assert.Equal(20, tools.Count);
Assert.Contains(tools, t => t.Name == "NewTool");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@@ -222,7 +222,7 @@ public async Task Can_Be_Notified_Of_Tool_Changes()
}
tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(16, tools.Count);
+ Assert.Equal(19, tools.Count);
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
}
@@ -380,6 +380,78 @@ public async Task Returns_IsError_Content_And_Logs_Error_When_Tool_Fails()
Assert.Equal("Test error", errorLog.Exception.Message);
}
+ [Fact]
+ public async Task Logs_Tool_Name_On_Successful_Call()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.CallToolAsync(
+ "echo",
+ new Dictionary { ["message"] = "test" },
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsError is not true);
+ Assert.Equal("hello test", (result.Content[0] as TextContentBlock)?.Text);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"echo\" completed. IsError = False.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Tool_Name_With_IsError_When_Tool_Returns_Error()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.CallToolAsync(
+ "return_is_error",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsError);
+ Assert.Contains("Tool returned an error", (result.Content[0] as TextContentBlock)?.Text);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"return_is_error\" completed. IsError = True.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task Logs_Tool_Error_When_Tool_Throws_OperationCanceledException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.CallToolAsync(
+ "throw_operation_canceled_exception",
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsError);
+ Assert.NotNull(result.Content);
+ Assert.NotEmpty(result.Content);
+ Assert.Contains("An error occurred", (result.Content[0] as TextContentBlock)?.Text);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "\"throw_operation_canceled_exception\" threw an unhandled exception." &&
+ m.Exception is OperationCanceledException);
+ }
+
+ [Fact]
+ public async Task Logs_Tool_Error_When_Tool_Throws_McpProtocolException()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.CallToolAsync(
+ "throw_mcp_protocol_exception",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Error &&
+ m.Message == "\"throw_mcp_protocol_exception\" threw an unhandled exception." &&
+ m.Exception is McpProtocolException);
+
+ Assert.Contains(MockLoggerProvider.LogMessages, m =>
+ m.LogLevel == LogLevel.Warning &&
+ m.Message.Contains("request handler failed"));
+ }
+
[Fact]
public async Task Throws_Exception_On_Unknown_Tool()
{
@@ -786,6 +858,28 @@ public static string ThrowException()
throw new InvalidOperationException("Test error");
}
+ [McpServerTool]
+ public static string ThrowOperationCanceledException()
+ {
+ throw new OperationCanceledException("Tool was canceled");
+ }
+
+ [McpServerTool]
+ public static string ThrowMcpProtocolException()
+ {
+ throw new McpProtocolException("Tool protocol error", McpErrorCode.InvalidParams);
+ }
+
+ [McpServerTool]
+ public static CallToolResult ReturnIsError()
+ {
+ return new CallToolResult
+ {
+ IsError = true,
+ Content = [new TextContentBlock { Text = "Tool returned an error" }],
+ };
+ }
+
[McpServerTool]
public static int ReturnCancellationToken(CancellationToken cancellationToken)
{
@@ -868,5 +962,6 @@ public class ComplexObject
[JsonSerializable(typeof(ComplexObject))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(JsonElement))]
+ [JsonSerializable(typeof(CallToolResult))]
partial class BuilderToolsJsonContext : JsonSerializerContext;
}
\ No newline at end of file
diff --git a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs b/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
index 0bf70b7c2..99c6035e6 100644
--- a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
@@ -530,6 +530,129 @@ public async Task SyncTool_WithRequiredTaskSupport_CannotBeCalledDirectly()
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
Assert.Contains("task", exception.Message, StringComparison.OrdinalIgnoreCase);
}
+
+ [Fact]
+ public async Task TaskPath_Logs_Tool_Name_On_Successful_Call()
+ {
+ var taskStore = new InMemoryMcpTaskStore();
+
+ await using var fixture = new ClientServerFixture(
+ LoggerFactory,
+ configureServer: builder =>
+ {
+ builder.WithTools([McpServerTool.Create(
+ (string input) => $"Result: {input}",
+ new McpServerToolCreateOptions
+ {
+ Name = "task-success-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ })]);
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(MockLoggerProvider);
+ services.AddSingleton(taskStore);
+ services.Configure(options => options.TaskStore = taskStore);
+ });
+
+ var mcpTask = await fixture.Client.CallToolAsTaskAsync(
+ "task-success-tool",
+ arguments: new Dictionary { ["input"] = "test" },
+ taskMetadata: new McpTaskMetadata(),
+ progress: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(mcpTask);
+
+ // Wait for the async task execution to complete
+ await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-success-tool\" completed. IsError = False.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task TaskPath_Logs_Tool_Name_With_IsError_When_Tool_Returns_Error()
+ {
+ var taskStore = new InMemoryMcpTaskStore();
+
+ await using var fixture = new ClientServerFixture(
+ LoggerFactory,
+ configureServer: builder =>
+ {
+ builder.WithTools([McpServerTool.Create(
+ () => new CallToolResult
+ {
+ IsError = true,
+ Content = [new TextContentBlock { Text = "Task tool error" }],
+ },
+ new McpServerToolCreateOptions
+ {
+ Name = "task-error-result-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ })]);
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(MockLoggerProvider);
+ services.AddSingleton(taskStore);
+ services.Configure(options => options.TaskStore = taskStore);
+ });
+
+ var mcpTask = await fixture.Client.CallToolAsTaskAsync(
+ "task-error-result-tool",
+ taskMetadata: new McpTaskMetadata(),
+ progress: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(mcpTask);
+
+ // Wait for the async task execution to complete
+ await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
+
+ var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-error-result-tool\" completed. IsError = True.");
+ Assert.Equal(LogLevel.Information, infoLog.LogLevel);
+ }
+
+ [Fact]
+ public async Task TaskPath_Logs_Error_When_Tool_Throws()
+ {
+ var taskStore = new InMemoryMcpTaskStore();
+
+ await using var fixture = new ClientServerFixture(
+ LoggerFactory,
+ configureServer: builder =>
+ {
+ builder.WithTools([McpServerTool.Create(
+ string () => throw new InvalidOperationException("Task tool error"),
+ new McpServerToolCreateOptions
+ {
+ Name = "task-throw-tool",
+ Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
+ })]);
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(MockLoggerProvider);
+ services.AddSingleton(taskStore);
+ services.Configure(options => options.TaskStore = taskStore);
+ });
+
+ var mcpTask = await fixture.Client.CallToolAsTaskAsync(
+ "task-throw-tool",
+ taskMetadata: new McpTaskMetadata(),
+ progress: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(mcpTask);
+
+ // Wait for the async task execution to complete
+ await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
+
+ var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("\"task-throw-tool\" threw an unhandled exception.", errorLog.Message);
+ Assert.IsType(errorLog.Exception);
+ }
#pragma warning restore MCPEXP001
#endregion