Propagate tool call exceptions through filters (#844)

* Add McpProtocolException specifically for JSON-RPC errors
This commit is contained in:
Stephen Halter 2025-10-14 21:10:55 -05:00 • committed by GitHub
commit 3255adb4d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 334 additions and 163 deletions

View file

@ -36,7 +36,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync(
"authorized_tool",
new Dictionary<string, object?> { ["message"] = "test" },
@ -101,7 +101,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync(
"admin_tool",
new Dictionary<string, object?> { ["message"] = "test" },
@ -188,7 +188,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.GetPromptAsync(
"authorized_prompt",
new Dictionary<string, object?> { ["message"] = "test" },
@ -235,7 +235,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.ReadResourceAsync(
"resource://authorized",
cancellationToken: TestContext.Current.CancellationToken));
@ -277,7 +277,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithTools<AuthorizationTestTools>());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
@ -289,21 +289,23 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
}
[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"));
@ -316,7 +318,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts<AuthorizationTestPrompts>());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
@ -334,7 +336,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithPrompts<AuthorizationTestPrompts>());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.GetPromptAsync(
"authorized_prompt",
new Dictionary<string, object?> { ["message"] = "test" },
@ -355,7 +357,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);
@ -373,7 +375,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.ReadResourceAsync(
"resource://authorized",
cancellationToken: TestContext.Current.CancellationToken));
@ -393,7 +395,7 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr
await using var app = await StartServerWithoutAuthFilters(builder => builder.WithResources<AuthorizationTestResources>());
var client = await ConnectAsync();
var exception = await Assert.ThrowsAsync<McpException>(async () =>
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal("Request failed (remote): An error occurred.", exception.Message);

View file

@ -240,7 +240,7 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
// act
await using var client = await GetClientAsync();
await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<McpProtocolException>(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]

View file

@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using ModelContextProtocol.Client;
using System.Collections.Concurrent;
namespace ModelContextProtocol.AspNetCore.Tests;
@ -148,7 +149,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
[Fact]
public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitialization()
{
var protocolVersionHeaderValues = new List<string?>();
var protocolVersionHeaderValues = new ConcurrentQueue<string?>();
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
@ -160,7 +161,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
{
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);

View file

@ -170,7 +170,7 @@ internal static class Program
{
if (request.Params?.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message))
{
throw new McpException("Missing required argument 'message'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required argument 'message'", McpErrorCode.InvalidParams);
}
return new CallToolResult
{
@ -190,7 +190,7 @@ internal static class Program
!request.Params.Arguments.TryGetValue("prompt", out var prompt) ||
!request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens))
{
throw new McpException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams);
}
var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "sampleLLM", Convert.ToInt32(maxTokens.GetRawText())),
cancellationToken);
@ -209,7 +209,7 @@ internal static class Program
}
else
{
throw new McpException($"Unknown tool: {request.Params?.Name}", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unknown tool: {request.Params?.Name}", McpErrorCode.InvalidParams);
}
};
}
@ -287,7 +287,7 @@ internal static class Program
}
else
{
throw new McpException($"Unknown prompt: {request.Params?.Name}", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unknown prompt: {request.Params?.Name}", McpErrorCode.InvalidParams);
}
return new GetPromptResult
@ -305,7 +305,7 @@ internal static class Program
{
if (request.Params?.Level is null)
{
throw new McpException("Missing required argument 'level'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required argument 'level'", McpErrorCode.InvalidParams);
}
_minimumLoggingLevel = request.Params.Level;
@ -387,7 +387,7 @@ internal static class Program
}
catch (Exception e)
{
throw new McpException($"Invalid cursor: '{request.Params.Cursor}'", e, McpErrorCode.InvalidParams);
throw new McpProtocolException($"Invalid cursor: '{request.Params.Cursor}'", e, McpErrorCode.InvalidParams);
}
}
@ -409,7 +409,7 @@ internal static class Program
{
if (request.Params?.Uri is null)
{
throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
}
if (request.Params.Uri.StartsWith("test://dynamic/resource/"))
@ -417,7 +417,7 @@ internal static class Program
var id = request.Params.Uri.Split('/').LastOrDefault();
if (string.IsNullOrEmpty(id))
{
throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
}
return new ReadResourceResult
@ -434,7 +434,7 @@ internal static class Program
}
ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri)
?? throw new McpException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
?? throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
return new ReadResourceResult
{
@ -446,12 +446,12 @@ internal static class Program
{
if (request?.Params?.Uri is null)
{
throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
}
if (!request.Params.Uri.StartsWith("test://static/resource/")
&& !request.Params.Uri.StartsWith("test://dynamic/resource/"))
{
throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
}
_subscribedResources.TryAdd(request.Params.Uri, true);
@ -463,12 +463,12 @@ internal static class Program
{
if (request?.Params?.Uri is null)
{
throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
}
if (!request.Params.Uri.StartsWith("test://static/resource/")
&& !request.Params.Uri.StartsWith("test://dynamic/resource/"))
{
throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
}
_subscribedResources.TryRemove(request.Params.Uri, out _);
@ -509,7 +509,7 @@ internal static class Program
return new CompleteResult { Completion = new() { Values = values, HasMore = false, Total = values.Length } };
default:
throw new McpException($"Unknown reference type: '{request.Params?.Ref.Type}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unknown reference type: '{request.Params?.Ref.Type}'", McpErrorCode.InvalidParams);
}
};
}

View file

@ -158,13 +158,13 @@ public class Program
{
if (request.Params is null)
{
throw new McpException("Missing required parameter 'name'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required parameter 'name'", McpErrorCode.InvalidParams);
}
if (request.Params.Name == "echo")
{
if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message))
{
throw new McpException("Missing required argument 'message'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required argument 'message'", McpErrorCode.InvalidParams);
}
return new CallToolResult
{
@ -184,7 +184,7 @@ public class Program
!request.Params.Arguments.TryGetValue("prompt", out var prompt) ||
!request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens))
{
throw new McpException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams);
}
var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "sampleLLM", Convert.ToInt32(maxTokens.ToString())),
cancellationToken);
@ -196,7 +196,7 @@ public class Program
}
else
{
throw new McpException($"Unknown tool: '{request.Params.Name}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unknown tool: '{request.Params.Name}'", McpErrorCode.InvalidParams);
}
},
ListResourceTemplatesHandler = async (request, cancellationToken) =>
@ -226,7 +226,7 @@ public class Program
}
catch (Exception e)
{
throw new McpException($"Invalid cursor: '{requestParams.Cursor}'", e, McpErrorCode.InvalidParams);
throw new McpProtocolException($"Invalid cursor: '{requestParams.Cursor}'", e, McpErrorCode.InvalidParams);
}
}
@ -248,7 +248,7 @@ public class Program
{
if (request.Params?.Uri is null)
{
throw new McpException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required argument 'uri'", McpErrorCode.InvalidParams);
}
if (request.Params.Uri.StartsWith("test://dynamic/resource/"))
@ -256,7 +256,7 @@ public class Program
var id = request.Params.Uri.Split('/').LastOrDefault();
if (string.IsNullOrEmpty(id))
{
throw new McpException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Invalid resource URI: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
}
return new ReadResourceResult
@ -273,7 +273,7 @@ public class Program
}
ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ??
throw new McpException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams);
return new ReadResourceResult
{
@ -317,7 +317,7 @@ public class Program
{
if (request.Params is null)
{
throw new McpException("Missing required parameter 'name'", McpErrorCode.InvalidParams);
throw new McpProtocolException("Missing required parameter 'name'", McpErrorCode.InvalidParams);
}
List<PromptMessage> messages = new();
if (request.Params.Name == "simple_prompt")
@ -354,7 +354,7 @@ public class Program
}
else
{
throw new McpException($"Unknown prompt: {request.Params.Name}", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unknown prompt: {request.Params.Name}", McpErrorCode.InvalidParams);
}
return new GetPromptResult

View file

@ -193,7 +193,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIn
// act
await using var client = await _fixture.CreateClientAsync(clientId);
await Assert.ThrowsAsync<McpException>(async () =>
await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
}

View file

@ -58,7 +58,18 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
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) =>
{
@ -162,6 +173,20 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
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()
{
@ -286,6 +311,12 @@ public class McpServerBuilderExtensionsFilterTests : ClientServerTestBase
{
return "test result";
}
[McpServerTool]
public static string ThrowingToolMethod()
{
throw new InvalidOperationException("This tool always throws an exception");
}
}
[McpServerPromptType]

View file

@ -62,7 +62,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
};
default:
throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
}
})
.WithGetPromptHandler(async (request, cancellationToken) =>
@ -78,7 +78,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
};
default:
throw new McpException($"Unknown prompt '{request.Params?.Name}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unknown prompt '{request.Params?.Name}'", McpErrorCode.InvalidParams);
}
})
.WithPrompts<SimplePrompts>();
@ -190,7 +190,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
{
await using McpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync(
await Assert.ThrowsAsync<McpProtocolException>(async () => await client.GetPromptAsync(
nameof(SimplePrompts.ThrowsException),
cancellationToken: TestContext.Current.CancellationToken));
}
@ -200,7 +200,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
{
await using McpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync(
var e = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.GetPromptAsync(
"NotRegisteredPrompt",
cancellationToken: TestContext.Current.CancellationToken));
@ -212,7 +212,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
{
await using McpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync(
var e = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.GetPromptAsync(
"returns_chat_messages",
cancellationToken: TestContext.Current.CancellationToken));

View file

@ -62,7 +62,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
};
default:
throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
}
})
.WithListResourceTemplatesHandler(async (request, cancellationToken) =>
@ -91,7 +91,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
}],
};
default:
throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
}
})
.WithReadResourceHandler(async (request, cancellationToken) =>
@ -109,7 +109,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
};
}
throw new McpException($"Resource not found: {request.Params?.Uri}");
throw new McpProtocolException($"Resource not found: {request.Params?.Uri}");
})
.WithResources<SimpleResources>();
}
@ -235,7 +235,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
{
await using McpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<McpException>(async () => await client.ReadResourceAsync(
await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ReadResourceAsync(
$"resource://mcp/{nameof(SimpleResources.ThrowsException)}",
cancellationToken: TestContext.Current.CancellationToken));
}
@ -245,7 +245,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
{
await using McpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.ReadResourceAsync(
var e = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ReadResourceAsync(
"test:///NotRegisteredResource",
cancellationToken: TestContext.Current.CancellationToken));

View file

@ -90,7 +90,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
};
default:
throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
}
})
.WithCallToolHandler(async (request, cancellationToken) =>
@ -106,7 +106,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
};
default:
throw new McpException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams);
throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams);
}
})
.WithTools<EchoTool>(serializerOptions: BuilderToolsJsonContext.Default.Options);
@ -388,7 +388,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
{
await using McpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.CallToolAsync(
var e = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.CallToolAsync(
"NotRegisteredTool",
cancellationToken: TestContext.Current.CancellationToken));

View file

@ -87,7 +87,7 @@ public class DiagnosticTests
await RunConnected(async (client, server) =>
{
await client.CallToolAsync("Throw", cancellationToken: TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<McpException>(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<McpProtocolException>(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken));
}, new List<string>());
}

View file

@ -248,7 +248,7 @@ public partial class ElicitationTypedTests : ClientServerTestBase
},
});
var ex = await Assert.ThrowsAsync<McpException>(async() =>
var ex = await Assert.ThrowsAsync<McpProtocolException>(async() =>
await client.CallToolAsync("TestElicitationUnsupportedType", cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains(typeof(UnsupportedForm.Nested).FullName!, ex.Message);
@ -270,7 +270,7 @@ public partial class ElicitationTypedTests : ClientServerTestBase
}
});
var ex = await Assert.ThrowsAsync<McpException>(async () =>
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync("TestElicitationNullablePropertyForm", cancellationToken: TestContext.Current.CancellationToken));
}
@ -290,7 +290,7 @@ public partial class ElicitationTypedTests : ClientServerTestBase
}
});
var ex = await Assert.ThrowsAsync<McpException>(async () =>
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync("TestElicitationNonObjectGenericType", cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains(typeof(string).FullName!, ex.Message);

View file

@ -532,6 +532,110 @@ public class McpServerTests : LoggedTest
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_McpException()
{
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 McpException(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_McpProtocolException()
{
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 McpProtocolException(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();