diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 2e5ad2841..5c631a0e7 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -75,4 +75,12 @@ internal static class McpHttpHeaders /// public static bool SupportsStandardHeaders(string? protocolVersion) => !string.IsNullOrEmpty(protocolVersion) && s_versionsWithStandardHeaders.Contains(protocolVersion!); + + /// + /// Returns if the negotiated protocol version reports unresolvable + /// resource URIs with the standard JSON-RPC (-32602) + /// rather than the legacy (-32002). + /// + internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) + => string.Equals(protocolVersion, MinVersionForStandardHeaders, StringComparison.Ordinal); } diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 38c5f1161..54b9eeebf 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -29,8 +29,17 @@ public enum McpErrorCode /// Indicates that the requested resource could not be found. /// /// - /// This error should be used when a resource URI does not match any available resource on the server. - /// It allows clients to distinguish between missing resources and other types of errors. + /// + /// Legacy error code for unresolvable resource URIs. Newer protocol versions report this + /// condition with the standard JSON-RPC (-32602) instead. The SDK + /// selects between the two automatically based on the negotiated protocol version, so older + /// clients still see (-32002) and newer ones see + /// . + /// + /// + /// New user code throwing directly for unknown-resource conditions + /// should prefer ; the SDK will pass the value through unchanged. + /// /// ResourceNotFound = -32002, @@ -85,6 +94,7 @@ public enum McpErrorCode /// /// Tools: Unknown tool name or invalid protocol-level tool arguments. /// Prompts: Unknown prompt name or missing required protocol-level arguments. + /// Resources: Unknown or unresolvable resource URI. /// Pagination: Invalid or expired cursor values. /// Logging: Invalid log level. /// Tasks: Invalid or nonexistent task ID or invalid cursor. diff --git a/src/ModelContextProtocol.Core/McpProtocolException.cs b/src/ModelContextProtocol.Core/McpProtocolException.cs index 3fbef91c0..7bcc4d0a8 100644 --- a/src/ModelContextProtocol.Core/McpProtocolException.cs +++ b/src/ModelContextProtocol.Core/McpProtocolException.cs @@ -76,7 +76,7 @@ public McpProtocolException(string message, Exception? innerException, McpErrorC /// -32700: Parse error - Invalid JSON received /// -32600: Invalid request - The JSON is not a valid Request object /// -32601: Method not found - The method does not exist or is not available - /// -32602: Invalid params - Malformed request or unknown primitive name (tool/prompt/resource) + /// -32602: Invalid params - Malformed request, unknown primitive name (tool/prompt/resource), or unresolvable resource URI /// -32603: Internal error - Internal JSON-RPC error /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 04d11e016..203856814 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -421,7 +421,13 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null && listResourcesHandler ??= (static async (_, __) => new ListResourcesResult()); listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult()); - readResourceHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", McpErrorCode.ResourceNotFound)); + readResourceHandler ??= (static async (request, _) => + { + var errorCode = McpHttpHeaders.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) + ? McpErrorCode.InvalidParams + : McpErrorCode.ResourceNotFound; + throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", errorCode); + }); subscribeHandler ??= (static async (_, __) => new EmptyResult()); unsubscribeHandler ??= (static async (_, __) => new EmptyResult()); var listChanged = resourcesCapability?.ListChanged; diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index 9cb963a96..aaa6104cc 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -503,7 +503,7 @@ private static void ConfigureResources(McpServerOptions options) } ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) - ?? throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.ResourceNotFound); + ?? throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); return new ReadResourceResult { diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index a36a0a6e0..e52a8ff1f 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -307,7 +307,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ?? - throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.ResourceNotFound); + throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); return new ReadResourceResult { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 4b03cadb2..d6bb239d7 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -109,7 +109,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; } - throw new McpProtocolException($"Resource not found: {request.Params.Uri}", McpErrorCode.ResourceNotFound); + throw new McpProtocolException($"Resource not found: {request.Params.Uri}", McpErrorCode.InvalidParams); }) .WithResources(); } @@ -317,7 +317,7 @@ public async Task Throws_Exception_On_Unknown_Resource() cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Resource not found", e.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, e.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, e.ErrorCode); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 19e0f1bbe..1956887ac 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -22,6 +23,23 @@ private async Task CreateClientWithResourcesAsync(params McpServerRes return await CreateMcpClientForServer(); } + /// + /// Starts the server with the specified resources, pins both the server's and the + /// client's protocol version to , and returns a + /// connected client. Both ends must be pinned because strictly + /// compares the server's negotiated version against the client's requested version and + /// refuses to connect on mismatch. + /// + private async Task CreateClientWithResourcesAndServerVersionAsync( + string protocolVersion, + params McpServerResource[] resources) + { + McpServerBuilder.WithResources(resources); + McpServerBuilder.Services.Configure(o => o.ProtocolVersion = protocolVersion); + StartServer(); + return await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = protocolVersion }); + } + /// /// Asserts that the given URI matches the template and produces the expected text result. /// @@ -56,6 +74,26 @@ private async Task AssertNoMatchAsync( Assert.Equal(McpErrorCode.ResourceNotFound, ex.ErrorCode); } + // Unknown-resource-URI responses are version-gated: older clients keep the legacy + // -32002 (McpErrorCode.ResourceNotFound), and clients on the draft protocol version that + // moves to the standard JSON-RPC code see -32602 (McpErrorCode.InvalidParams). + [Theory] + [InlineData("2025-11-25", McpErrorCode.ResourceNotFound)] + [InlineData("DRAFT-2026-v1", McpErrorCode.InvalidParams)] + public async Task ResourceNotFound_ErrorCode_IsVersionGated(string serverProtocolVersion, McpErrorCode expectedCode) + { + var resource = McpServerResource.Create( + options: new() { UriTemplate = "test://known/{id}" }, + method: (string id) => $"ok: {id}"); + + var client = await CreateClientWithResourcesAndServerVersionAsync(serverProtocolVersion, resource); + + var ex = await Assert.ThrowsAsync(async () => + await client.ReadResourceAsync("test://unknown", null, TestContext.Current.CancellationToken)); + + Assert.Equal(expectedCode, ex.ErrorCode); + } + /// /// Verify that when multiple templated resources exist, the correct one is matched based on the URI pattern. /// Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/821. diff --git a/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs b/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs index 7d50a3044..336f58382 100644 --- a/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs +++ b/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs @@ -33,7 +33,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer switch (toolName) { case "throw_with_serializable_data": - throw new McpProtocolException("Resource not found", McpErrorCode.ResourceNotFound) + throw new McpProtocolException("Resource not found", McpErrorCode.InvalidParams) { Data = { @@ -43,7 +43,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; case "throw_with_nonserializable_data": - throw new McpProtocolException("Resource not found", McpErrorCode.ResourceNotFound) + throw new McpProtocolException("Resource not found", McpErrorCode.InvalidParams) { Data = { @@ -55,7 +55,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; case "throw_with_only_nonserializable_data": - throw new McpProtocolException("Resource not found", McpErrorCode.ResourceNotFound) + throw new McpProtocolException("Resource not found", McpErrorCode.InvalidParams) { Data = { @@ -79,7 +79,7 @@ public async Task Exception_With_Serializable_Data_Propagates_To_Client() await client.CallToolAsync("throw_with_serializable_data", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): Resource not found", exception.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, exception.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); // Verify the data was propagated to the exception // The Data collection should contain the expected keys @@ -113,7 +113,7 @@ public async Task Exception_With_NonSerializable_Data_Still_Propagates_Error_To_ await client.CallToolAsync("throw_with_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): Resource not found", exception.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, exception.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); // Verify that only the serializable data was propagated (non-serializable was filtered out) var hasUri = false; @@ -142,7 +142,7 @@ public async Task Exception_With_Only_NonSerializable_Data_Still_Propagates_Erro await client.CallToolAsync("throw_with_only_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): Resource not found", exception.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, exception.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); // When all data is non-serializable, the Data collection should be empty // (the server's ConvertExceptionData returns null when no serializable data exists) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index d9febd721..b8bd57b02 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -1033,7 +1033,7 @@ await transport.SendMessageAsync( public async Task Can_Handle_Call_Tool_Requests_With_McpProtocolException_And_Data() { const string ErrorMessage = "Resource not found"; - const McpErrorCode ErrorCode = McpErrorCode.ResourceNotFound; + const McpErrorCode ErrorCode = McpErrorCode.InvalidParams; const string ResourceUri = "file:///path/to/resource"; await using var transport = new TestServerTransport();