Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/ModelContextProtocol.Core/McpServerToolException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace ModelContextProtocol;

/// <summary>
/// Represents an exception that is thrown by an MCP tool to communicate detailed error information.
/// </summary>
/// <remarks>
/// This exception is used by MCP tools to provide detailed error messages when a tool execution fails.
/// Unlike <see cref="McpException"/>, this exception is intended for application-level errors within tool calls.
/// and does not include JSON-RPC error codes. The <see cref="Exception.Message"/> from this exception
/// will be propagated to the remote endpoint to inform the caller about the tool execution failure.
/// </remarks>
public class McpServerToolException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="McpServerToolException"/> class.
/// </summary>
public McpServerToolException()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="McpServerToolException"/> class with a specified error message.
/// </summary>
/// <param name="message">The message that describes the error.</param>
public McpServerToolException(string message) : base(message)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="McpServerToolException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
public McpServerToolException(string message, Exception? innerException) : base(message, innerException)
{
}
}
52 changes: 16 additions & 36 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)

listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.ListToolsFilters);
callToolHandler = BuildFilterPipeline(callToolHandler, options.Filters.CallToolFilters, handler =>
(request, cancellationToken) =>
async (request, cancellationToken) =>
{
// Initial handler that sets MatchedPrimitive
if (request.Params?.Name is { } toolName && tools is not null &&
Expand All @@ -589,37 +589,23 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
request.MatchedPrimitive = tool;
}

return handler(request, cancellationToken);
}, handler =>
async (request, cancellationToken) =>
{
// Final handler that provides exception handling only for tool execution
// Only wrap tool execution in try-catch, not tool resolution
if (request.MatchedPrimitive is McpServerTool)
try
{
try
{
return await handler(request, cancellationToken).ConfigureAwait(false);
}
catch (Exception e) when (e is not OperationCanceledException)
{
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}'.";

return new()
{
IsError = true,
Content = [new TextContentBlock { Text = errorMessage }],
};
}
return await handler(request, cancellationToken);
}
else
catch (Exception e) when (e is not OperationCanceledException and not McpException)
{
// For unmatched tools, let exceptions bubble up as protocol errors
return await handler(request, cancellationToken).ConfigureAwait(false);
ToolCallError(request.Params?.Name ?? string.Empty, e);

string errorMessage = e is McpServerToolException ?
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.";

return new()
{
IsError = true,
Content = [new TextContentBlock { Text = errorMessage }],
};
}
});

Expand Down Expand Up @@ -735,16 +721,10 @@ private void SetHandler<TParams, TResult>(
private static McpRequestHandler<TParams, TResult> BuildFilterPipeline<TParams, TResult>(
McpRequestHandler<TParams, TResult> baseHandler,
List<McpRequestFilter<TParams, TResult>> filters,
McpRequestFilter<TParams, TResult>? initialHandler = null,
McpRequestFilter<TParams, TResult>? finalHandler = null)
McpRequestFilter<TParams, TResult>? initialHandler = null)
{
var current = baseHandler;

if (finalHandler is not null)
{
current = finalHandler(current);
}

for (int i = filters.Count - 1; i >= 0; i--)
{
current = filters[i](current);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,21 +289,23 @@ log.Exception is InvalidOperationException &&
}

[Fact]
public async Task CallTool_WithoutAuthFilters_ThrowsInvalidOperationException()
public async Task CallTool_WithoutAuthFilters_ReturnsError()
{
_mockLoggerProvider.LogMessages.Clear();
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools<AuthorizationTestTools>());
var client = await ConnectAsync();

var exception = await Assert.ThrowsAsync<McpException>(async () =>
await client.CallToolAsync(
var toolResult = await client.CallToolAsync(
"authorized_tool",
new Dictionary<string, object?> { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken));
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
Assert.True(toolResult.IsError);

var errorContent = Assert.IsType<TextContentBlock>(Assert.Single(toolResult.Content));
Assert.Equal("An error occurred invoking 'authorized_tool'.", errorContent.Text);
Assert.Contains(_mockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.LogLevel == LogLevel.Error &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("Authorization filter was not invoked for tools/call operation") &&
log.Exception.Message.Contains("Ensure that AddAuthorizationFilters() is called"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using ModelContextProtocol.Client;
using System.Collections.Concurrent;

namespace ModelContextProtocol.AspNetCore.Tests;

Expand Down Expand Up @@ -148,7 +149,7 @@ public async Task SseMode_Works_WithSseEndpoint()
[Fact]
public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitialization()
{
var protocolVersionHeaderValues = new List<string?>();
var protocolVersionHeaderValues = new ConcurrentQueue<string?>();

Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();

Expand All @@ -160,7 +161,7 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia
{
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-protocol-version"]))
{
protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]);
protocolVersionHeaderValues.Enqueue(context.Request.Headers["mcp-protocol-version"]);
}

await next(context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,18 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
var logger = GetLogger(request.Services, "CallToolFilter");
var primitiveId = request.MatchedPrimitive?.Id ?? "unknown";
logger.LogInformation($"CallToolFilter executed for tool: {primitiveId}");
return await next(request, cancellationToken);
try
{
return await next(request, cancellationToken);
}
catch (Exception ex)
{
return new CallToolResult
{
Content = [new TextContentBlock { Type = "text", Text = $"Error from filter: {ex.Message}" }],
IsError = true
};
}
})
.AddListPromptsFilter((next) => async (request, cancellationToken) =>
{
Expand Down Expand Up @@ -162,6 +173,20 @@ public async Task AddCallToolFilter_Logs_When_CallTool_Called()
Assert.Equal("CallToolFilter", logMessage.Category);
}

[Fact]
public async Task AddCallToolFilter_Catches_Exception_From_Tool()
{
await using McpClient client = await CreateMcpClientForServer();

var result = await client.CallToolAsync("throwing_tool_method", cancellationToken: TestContext.Current.CancellationToken);

Assert.True(result.IsError);
Assert.NotNull(result.Content);
var textContent = Assert.Single(result.Content);
var textBlock = Assert.IsType<TextContentBlock>(textContent);
Assert.Equal("Error from filter: This tool always throws an exception", textBlock.Text);
}

[Fact]
public async Task AddListPromptsFilter_Logs_When_ListPrompts_Called()
{
Expand Down Expand Up @@ -286,6 +311,12 @@ public static string TestToolMethod()
{
return "test result";
}

[McpServerTool]
public static string ThrowingToolMethod()
{
throw new InvalidOperationException("This tool always throws an exception");
}
}

[McpServerPromptType]
Expand Down
104 changes: 104 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,110 @@ public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_A
await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured");
}

[Fact]
public async Task Can_Handle_Call_Tool_Requests_With_McpServerToolException()
{
const string errorMessage = "Tool execution failed with detailed error";
await Can_Handle_Requests(
new ServerCapabilities
{
Tools = new()
},
method: RequestMethods.ToolsCall,
configureOptions: options =>
{
options.Handlers.CallToolHandler = async (request, ct) =>
{
throw new McpServerToolException(errorMessage);
};
options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
},
assertResult: (_, response) =>
{
var result = JsonSerializer.Deserialize<CallToolResult>(response, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.True(result.IsError);
Assert.NotEmpty(result.Content);
var textContent = Assert.IsType<TextContentBlock>(result.Content[0]);
Assert.Contains(errorMessage, textContent.Text);
});
}

[Fact]
public async Task Can_Handle_Call_Tool_Requests_With_Plain_Exception()
{
await Can_Handle_Requests(
new ServerCapabilities
{
Tools = new()
},
method: RequestMethods.ToolsCall,
configureOptions: options =>
{
options.Handlers.CallToolHandler = async (request, ct) =>
{
throw new InvalidOperationException("This sensitive message should not be exposed");
};
options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
},
assertResult: (_, response) =>
{
var result = JsonSerializer.Deserialize<CallToolResult>(response, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.True(result.IsError);
Assert.NotEmpty(result.Content);
var textContent = Assert.IsType<TextContentBlock>(result.Content[0]);
// Should be a generic error message, not the actual exception message
Assert.DoesNotContain("sensitive", textContent.Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("An error occurred", textContent.Text);
});
}

[Fact]
public async Task Can_Handle_Call_Tool_Requests_With_McpException()
{
const string errorMessage = "Invalid tool parameters";
const McpErrorCode errorCode = McpErrorCode.InvalidParams;

await using var transport = new TestServerTransport();
var options = CreateOptions(new ServerCapabilities { Tools = new() });
options.Handlers.CallToolHandler = async (request, ct) =>
{
throw new McpException(errorMessage, errorCode);
};
options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();

await using var server = McpServer.Create(transport, options, LoggerFactory);

var runTask = server.RunAsync(TestContext.Current.CancellationToken);

var receivedMessage = new TaskCompletionSource<JsonRpcError>();

transport.OnMessageSent = (message) =>
{
if (message is JsonRpcError error && error.Id.ToString() == "55")
receivedMessage.SetResult(error);
};

await transport.SendMessageAsync(
new JsonRpcRequest
{
Method = RequestMethods.ToolsCall,
Id = new RequestId(55)
},
TestContext.Current.CancellationToken
);

var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.NotNull(error.Error);
Assert.Equal((int)errorCode, error.Error.Code);
Assert.Equal(errorMessage, error.Error.Message);

await transport.DisposeAsync();
await runTask;
}

private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action<McpServerOptions>? configureOptions, Action<McpServer, JsonNode?> assertResult)
{
await using var transport = new TestServerTransport();
Expand Down
Loading