Log tool call name on success, not just failure (#1256)

This commit is contained in:
Copilot 2026-02-13 09:24:02 -08:00 • committed by GitHub
commit 53e9ed8f65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 443 additions and 22 deletions

View file

@ -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}.";

View file

@ -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]

View file

@ -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;
}