Log tool call name on success, not just failure (#1256)
This commit is contained in:
parent
dbf91bcb1a
commit
53e9ed8f65
5 changed files with 443 additions and 22 deletions
|
|
@ -392,7 +392,17 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
}
|
||||
}
|
||||
|
||||
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 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
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 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
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 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
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 sealed partial class McpServerImpl : McpServer
|
|||
[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);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a tool call as a task and returns a CallToolTaskResult immediately.
|
||||
/// </summary>
|
||||
|
|
@ -1004,6 +1054,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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 partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
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 partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
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<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
|
|
@ -151,7 +152,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
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 partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
}
|
||||
|
||||
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 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
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<string, object?> { ["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<McpProtocolException>(async () => await client.GetPromptAsync(
|
||||
"throws_exception",
|
||||
new Dictionary<string, object?> { ["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<FormatException>(errorLog.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logs_Prompt_Error_When_Prompt_Throws_OperationCanceledException()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
await Assert.ThrowsAsync<McpProtocolException>(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<McpProtocolException>(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 partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
|
|||
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}.";
|
||||
|
|
|
|||
|
|
@ -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 partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
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 partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
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<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
|
|
@ -185,7 +186,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
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 partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
}
|
||||
|
||||
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 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
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<McpProtocolException>(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<InvalidOperationException>(errorLog.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logs_Resource_Error_When_Resource_Throws_OperationCanceledException()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
await Assert.ThrowsAsync<McpProtocolException>(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<McpProtocolException>(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 partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
|
|||
|
||||
[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]
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
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 partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
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 partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
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<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
|
|
@ -212,7 +212,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
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 partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
}
|
||||
|
||||
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 partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
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<string, object?> { ["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<McpProtocolException>(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 partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
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 partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
|
|||
[JsonSerializable(typeof(ComplexObject))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(CallToolResult))]
|
||||
partial class BuilderToolsJsonContext : JsonSerializerContext;
|
||||
}
|
||||
|
|
@ -530,6 +530,129 @@ public class ToolTaskSupportTests : LoggedTest
|
|||
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<ILoggerProvider>(MockLoggerProvider);
|
||||
services.AddSingleton<IMcpTaskStore>(taskStore);
|
||||
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
|
||||
});
|
||||
|
||||
var mcpTask = await fixture.Client.CallToolAsTaskAsync(
|
||||
"task-success-tool",
|
||||
arguments: new Dictionary<string, object?> { ["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<ILoggerProvider>(MockLoggerProvider);
|
||||
services.AddSingleton<IMcpTaskStore>(taskStore);
|
||||
services.Configure<McpServerOptions>(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<ILoggerProvider>(MockLoggerProvider);
|
||||
services.AddSingleton<IMcpTaskStore>(taskStore);
|
||||
services.Configure<McpServerOptions>(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<InvalidOperationException>(errorLog.Exception);
|
||||
}
|
||||
#pragma warning restore MCPEXP001
|
||||
|
||||
#endregion
|
||||
|
|
|
|||
Loading…
Reference in a new issue