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