diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md index 6096ae3..15ed40f 100644 --- a/docs/mcp-conformance.md +++ b/docs/mcp-conformance.md @@ -33,6 +33,7 @@ nothing a client can *observe* may depend on the connection. | `cacheScope` / `ttlMs` on list results | Absent — not in the schema | Set to private, zero TTL | `Given_McpIntegration.When_ClientPinsLegacyProtocolVersion_Then_ListResultsCarryNoCacheHints` / `Given_McpConcurrentSessions.When_ModernClientListsTools_Then_ListResultIsTaggedPrivateAndStale` | | Message notifications for a request that declared no log level | Emitted, subject to the session threshold | **Not emitted** — the feedback rides in the result instead | `Given_McpUserFeedback.When_RequestDeclaresNoLogLevel_Then_FeedbackRidesInTheToolResultInstead` | | Same, through `prompts/get` | Emitted | Rides in the prompt result after the payload, and in the surfaced error when the prompt fails | `Given_McpUserFeedback.When_APromptDeclaresNoLogLevel_Then_FeedbackRidesInThePromptResultInstead` / `...When_AFailingPromptDeclaresNoLogLevel_Then_FeedbackRidesInTheError` | +| Error code for an unknown resource URI | `ResourceNotFound` (-32002) | `InvalidParams` (-32602) | `Given_McpProtocolErrorCodes.When_ReadingUnknownResource_Then_ResourceNotFoundCodeIsReturned` / `...When_ReadingUnknownResourceOnTheSessionlessRevision_Then_InvalidParamsCodeIsReturned` | ## Tool list invariance on `2026-07-28` @@ -136,6 +137,7 @@ returned by a creation tool and passed back as an argument, rather than implicit | Identifier | Status in Repl | | --- | --- | +| SEP-2164 — standard error codes for an unknown resource or prompt | The codes the SDK's own handlers use: `InvalidParams` for an unknown prompt name on every revision, and for an unknown resource URI as the table above shows by revision | | SEP-2549 — `cacheScope` / `ttlMs` | Set on list and resource results, on `2026-07-28` only | | SEP-2575 — stateless requests: per-request `_meta`, and no message notification without a declared log level | The protocol version in `_meta` is what selects the era on every request; the log-level rule is honoured, and the feedback is appended to the result instead | | SEP-2577 — Roots, Sampling and Logging deprecated | Still supported for the compatibility path; the SDK reports them under diagnostic `MCP9005` | diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 4f7bf72..aed04ca 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -408,7 +408,13 @@ private async ValueTask ReadResourceAsync( var resource = snapshot.Resources.FirstOrDefault(candidate => candidate.IsMatch(uri)); if (resource is null) { - throw new McpException($"Unknown resource: {uri}"); + // The code the SDK's own resource handler uses: 2026-07-28 reports an unresolvable URI with the + // standard InvalidParams, earlier revisions with the legacy ResourceNotFound. A code-less + // McpException would surface as InternalError, which reads as a broken server, not a missing + // resource (SEP-2164). + throw new McpProtocolException( + $"Unknown resource: {uri}", + IsSessionlessRequest() ? McpErrorCode.InvalidParams : McpErrorCode.ResourceNotFound); } return await resource.ReadAsync(request, cancellationToken).ConfigureAwait(false); @@ -439,7 +445,8 @@ private async ValueTask GetPromptAsync( string.Equals(candidate.ProtocolPrompt.Name, promptName, StringComparison.OrdinalIgnoreCase)); if (prompt is null) { - throw new McpException($"Unknown prompt: {promptName}"); + // InvalidParams on every revision, as the SDK's own prompt handler answers an unknown name. + throw new McpProtocolException($"Unknown prompt: {promptName}", McpErrorCode.InvalidParams); } return await prompt.GetAsync(request, cancellationToken).ConfigureAwait(false); diff --git a/src/Repl.McpTests/Given_McpProtocolErrorCodes.cs b/src/Repl.McpTests/Given_McpProtocolErrorCodes.cs new file mode 100644 index 0000000..66cae82 --- /dev/null +++ b/src/Repl.McpTests/Given_McpProtocolErrorCodes.cs @@ -0,0 +1,63 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; +using Repl.Mcp; + +namespace Repl.McpTests; + +/// +/// An unknown resource or prompt is a protocol error with a standard code, not an internal error: +/// hosts branch on the code to tell "gone" from "broken" (SEP-2164). The codes follow the SDK's own +/// handlers, so a Repl server answers exactly as a server built on the SDK alone would. +/// +[TestClass] +public sealed class Given_McpProtocolErrorCodes +{ + [TestMethod] + [Description("An initialize-era client reading an unknown resource URI gets the legacy ResourceNotFound code (-32002), which is what the SDK's own resource handler returns on revisions before 2026-07-28. It used to get a code-less McpException, surfaced as InternalError (-32603).")] + public async Task When_ReadingUnknownResource_Then_ResourceNotFoundCodeIsReturned() + { + var error = await ReadUnknownResourceAsync(McpProtocolRevisions.LastWithSessions).ConfigureAwait(false); + + error.ErrorCode.Should().Be(McpErrorCode.ResourceNotFound); + } + + [TestMethod] + [Description("On 2026-07-28 an unresolvable resource URI is reported with the standard JSON-RPC InvalidParams (-32602), not the legacy ResourceNotFound — the SDK selects between the two by negotiated revision, and hard-coding the legacy code would answer a modern client with a code its revision no longer uses.")] + public async Task When_ReadingUnknownResourceOnTheSessionlessRevision_Then_InvalidParamsCodeIsReturned() + { + var error = await ReadUnknownResourceAsync(McpProtocolRevisions.Sessionless).ConfigureAwait(false); + + error.ErrorCode.Should().Be(McpErrorCode.InvalidParams); + } + + [TestMethod] + [DataRow(McpProtocolRevisions.LastWithSessions)] + [DataRow(McpProtocolRevisions.Sessionless)] + [Description("prompts/get with an unknown name is InvalidParams (-32602) on every revision, as the SDK's own prompt handler answers it. It used to surface as InternalError (-32603).")] + public async Task When_GettingUnknownPrompt_Then_InvalidParamsCodeIsReturned(string protocolVersion) + { + await using var fixture = await CreateFixtureAsync(protocolVersion).ConfigureAwait(false); + + var act = async () => await fixture.Client.GetPromptAsync("missing").ConfigureAwait(false); + + var error = await act.Should().ThrowAsync().ConfigureAwait(false); + error.Which.ErrorCode.Should().Be(McpErrorCode.InvalidParams); + } + + private static async Task ReadUnknownResourceAsync(string protocolVersion) + { + var fixture = await CreateFixtureAsync(protocolVersion).ConfigureAwait(false); + await using (fixture.ConfigureAwait(false)) + { + var act = async () => await fixture.Client.ReadResourceAsync("repl://missing").ConfigureAwait(false); + + return (await act.Should().ThrowAsync().ConfigureAwait(false)).Which; + } + } + + private static Task CreateFixtureAsync(string protocolVersion) => + McpTestFixture.CreateAsync( + app => app.Map("status", () => "ok").ReadOnly(), + configureOptions: null, + clientOptions: new McpClientOptions { ProtocolVersion = protocolVersion }); +}