Skip to content
15 changes: 14 additions & 1 deletion src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,16 @@ 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)
{
Expand Down Expand Up @@ -944,6 +953,9 @@ 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);

/// <summary>
/// Executes a tool call as a task and returns a CallToolTaskResult immediately.
/// </summary>
Expand Down Expand Up @@ -1004,6 +1016,7 @@ private async ValueTask<CallToolResult> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(17, tools.Count);

McpClientTool echoTool = tools.First(t => t.Name == "echo");
Assert.Equal("Echoes the input back to the client.", echoTool.Description);
Expand Down Expand Up @@ -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(17, tools.Count);

McpClientTool echoTool = tools.First(t => t.Name == "echo");
Assert.Equal("Echoes the input back to the client.", echoTool.Description);
Expand All @@ -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(17, tools.Count);

Channel<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
Expand All @@ -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(18, tools.Count);
Assert.Contains(tools, t => t.Name == "NewTool");

notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
Expand All @@ -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(17, tools.Count);
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
}

Expand Down Expand Up @@ -380,6 +380,39 @@ 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()
Comment thread
halter73 marked this conversation as resolved.
{
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 Throws_Exception_On_Unknown_Tool()
{
Expand Down Expand Up @@ -786,6 +819,16 @@ public static string ThrowException()
throw new InvalidOperationException("Test error");
}

[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)
{
Expand Down Expand Up @@ -868,5 +911,6 @@ public class ComplexObject
[JsonSerializable(typeof(ComplexObject))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(CallToolResult))]
partial class BuilderToolsJsonContext : JsonSerializerContext;
}
123 changes: 123 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 briefly for the async task execution to complete
await Task.Delay(500, TestContext.Current.CancellationToken);
Comment thread
halter73 marked this conversation as resolved.
Outdated

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 briefly for the async task execution to complete
await Task.Delay(500, 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 briefly for the async task execution to complete
await Task.Delay(500, 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
Expand Down