From 8754e19f8084669c6505068423043ab4310e33d5 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Fri, 14 Aug 2026 11:40:57 -0400 Subject: [PATCH 1/8] Fix protocol version tag for stateless server spans (#1810) Co-authored-by: Luis Rodriguez <25299418+luisangelrod@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed --- .../McpSessionHandler.cs | 5 +- .../MapMcpStatelessTests.cs | 53 ++++++++++++++++++ .../MapMcpStreamableHttpTests.cs | 56 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 61a1872..92b42de 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -962,9 +962,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable tags.Add("network.protocol.name", "http"); } - if (NegotiatedProtocolVersion is not null) + string? protocolVersion = (message as JsonRpcRequest)?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; + if (protocolVersion is not null) { - tags.Add("mcp.protocol.version", NegotiatedProtocolVersion); + tags.Add("mcp.protocol.version", protocolVersion); } if (activity is { IsAllDataRequested: true }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs index ac19953..fbf1a5f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using OpenTelemetry.Trace; +using System.Diagnostics; namespace ModelContextProtocol.AspNetCore.Tests; @@ -10,6 +12,57 @@ public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStream protected override bool UseStreamableHttp => true; protected override bool Stateless => true; + // In stateless mode each HTTP request is served by a fresh, un-negotiated McpServer, so the + // session-level NegotiatedProtocolVersion is null when the server span is tagged. The tag must + // therefore come from the per-request MCP-Protocol-Version header / _meta value. Exercising more + // than one version proves the tag tracks the per-request value rather than coincidentally matching + // a single hard-coded version (and would regress to an absent tag if the per-request fallback were + // removed, since the negotiated value is null at tagging time). + [Theory] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ServerActivity_TagsPerRequestProtocolVersion_InStatelessMode(string protocolVersion) + { + var activities = new List(); + string? capturedNegotiatedProtocolVersion = null; + + var protocolVersionTool = McpServerTool.Create( + (RequestContext context) => + { + capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; + return "ok"; + }, + new() { Name = "stateless-capture-version" }); + + using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.ModelContextProtocol") + .AddInMemoryExporter(activities) + .Build()) + { + Builder.Services.AddMcpServer() + .WithHttpTransport(ConfigureStateless) + .WithTools([protocolVersionTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var client = await ConnectAsync(configureClient: options => + options.ProtocolVersion = protocolVersion); + + await client.CallToolAsync("stateless-capture-version", cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Contains(activities, activity => + activity.DisplayName == "tools/call stateless-capture-version" && + activity.Kind == ActivityKind.Server && + activity.GetTagItem("mcp.protocol.version") as string == protocolVersion); + + // Once the per-request version is applied, the request-scoped server settles on that same version, + // so the value tagged on the span and the version the session ends up negotiating agree. + Assert.Equal(protocolVersion, capturedNegotiatedProtocolVersion); + } + [Fact] public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 889a7da..0a0a6bb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -6,7 +6,9 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using OpenTelemetry.Trace; using System.Collections.Concurrent; +using System.Diagnostics; using System.Net; using System.Security.Claims; using System.Threading; @@ -46,6 +48,60 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name); } + // 2025-11-25 is negotiated through the initialize handshake, so in the (default) stateful configuration + // the session-level NegotiatedProtocolVersion is populated and the client sends a matching + // MCP-Protocol-Version header on every follow-up request. This asserts the span carries the negotiated + // version and that the tagged value agrees with the session's NegotiatedProtocolVersion. + // + // A scenario where the per-request and negotiated versions differ is intentionally not asserted here: + // a single MCP session must not change protocol versions, so the server rejects a follow-up request + // whose header/_meta version differs from the negotiated one, and a conformant client never sends a + // differing header. Reaching that divergence would require a hand-crafted request on an invalid + // (failing) code path and would pin the current ordering of tagging relative to version-change + // validation. Instead the two inputs to the tag are covered independently: the per-request path in + // stateless mode (MapMcpStatelessTests, where NegotiatedProtocolVersion is null at tagging time) and the + // negotiated-only fallback over a header-less transport (DiagnosticTests). + [Fact] + public async Task ServerActivity_TagsNegotiatedProtocolVersion() + { + var activities = new List(); + string? capturedNegotiatedProtocolVersion = null; + + var protocolVersionTool = McpServerTool.Create( + (RequestContext context) => + { + capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; + return "ok"; + }, + new() { Name = "negotiated-capture-version" }); + + using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.ModelContextProtocol") + .AddInMemoryExporter(activities) + .Build()) + { + Builder.Services.AddMcpServer() + .WithHttpTransport(ConfigureStateless) + .WithTools([protocolVersionTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var client = await ConnectAsync(configureClient: options => + options.ProtocolVersion = "2025-11-25"); + + await client.CallToolAsync("negotiated-capture-version", cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Contains(activities, activity => + activity.DisplayName == "tools/call negotiated-capture-version" && + activity.Kind == ActivityKind.Server && + activity.GetTagItem("mcp.protocol.version") as string == "2025-11-25"); + + Assert.Equal("2025-11-25", capturedNegotiatedProtocolVersion); + } + [Fact] public async Task StreamableHttpMode_Works_WithRootEndpoint() { From 20cdd493a4554e39a052f340c9d8edd3b9aecd77 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 18 Aug 2026 17:58:17 -0700 Subject: [PATCH 2/8] Document standing support for new MCP revisions (#1826) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index af0f075..e012cc3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,7 @@ The C# SDK tracks implementation of MCP spec components using the [modelcontextp ### Next spec revision -The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). +The C# SDK's standing plan is to release support for each new MCP specification revision alongside the specification, as it did for the 2026-07-28 revision. Accepted SEPs are tracked on the revision's project board throughout development so implementation is ready when the revision is published. The next revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). ### Feedback and end-to-end scenarios From 609499b2988e3f4a4c732552290bc9241fb90db9 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 19 Aug 2026 17:12:21 -0700 Subject: [PATCH 3/8] Add docs source selector for manual publishes (#1828) Copilot-Session: 155ceb73-c05f-4eb6-9968-f5bbf90ed6d0 --- .github/release-process.md | 5 +- .github/skills/verify-release/SKILL.md | 29 ++++++---- .github/workflows/docs.yml | 79 +++++++++++++++----------- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/.github/release-process.md b/.github/release-process.md index 79a91d1..d7418b0 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -49,4 +49,7 @@ monitor both and confirm their published outputs: published release tags and deploys it to [csharp.sdk.modelcontextprotocol.io](https://csharp.sdk.modelcontextprotocol.io). Verify the new version appears in the version picker and that its major-version path serves the updated content. - A content-only docs refresh can be run later via manual dispatch with the `docs_ref` input. + A content-only docs refresh can be run later via manual dispatch. Its `docs_source` input defaults + to `release-tags` to mirror a release deployment; select `latest-branches` to refresh every + published major from its current `release/{MAJOR}.x` branch, using `main` for its + matching major. diff --git a/.github/skills/verify-release/SKILL.md b/.github/skills/verify-release/SKILL.md index 1f8a706..ad54776 100644 --- a/.github/skills/verify-release/SKILL.md +++ b/.github/skills/verify-release/SKILL.md @@ -103,11 +103,14 @@ Report the run's conclusion, accounting for these docs-specific behaviors: - **Version discovery reads published releases.** For each major version >= 1, the workflow takes the most recently published non-draft release tagged `v{MAJOR}.*`. A draft release contributes nothing. -- **Every major is rebuilt.** Each major's docs are built from that major's latest release tag into - its own path (`/v1/`, `/v2/`). A new MAJOR adds a new path; the site root redirects to the newest - release, prereleases included. +- **Every major is rebuilt.** A release-triggered run builds each major from its latest release tag + into its own path (`/v1/`, `/v2/`). A manual dispatch defaults to the same release-tag content, + or can build every major from `release/{MAJOR}.x`, using `main` for its matching major, + by selecting `docs_source=latest-branches`. A new MAJOR adds a new path; the site root redirects + to the newest release, prereleases included. - **Orchestration comes from `main`.** The scripts and picker assets are always checked out from - `main`, while each version's content comes from its release tag. A docs fix that lives only in a + `main`. Release-triggered and default manual runs use release-tag content; a manual + `docs_source=latest-branches` run uses source-branch content. A docs fix that lives only in a release branch will not affect orchestration. If it failed, summarize the failing step. Common causes are a docs build failure in one version's @@ -164,17 +167,21 @@ Both remediations require explicit user confirmation. gh run rerun {run-id} --failed ``` -**Refresh the docs without a new release** — when documentation content needs correcting after the -release, the docs workflow accepts a manual dispatch that rebuilds one major version's content from -an arbitrary ref, without minting a product release: +**Rebuild the released docs** — use the default `release-tags` source to retry a failed Pages +deployment after a release, without minting a product release: ``` -gh workflow run docs.yml --field docs_ref={branch-tag-or-commit} +gh workflow run docs.yml --field docs_source=release-tags ``` -The ref's major version, read from `src/Directory.Build.props`, must have a published release; the -workflow fails fast if it does not. This replaces only the matching major's HTML — orchestration -and all other versions are unaffected. +**Refresh docs without a new release** — select `latest-branches` to rebuild every published major +from its current source branch (`release/{MAJOR}.x`, using `main` for its matching major): + +``` +gh workflow run docs.yml --field docs_source=latest-branches +``` + +The workflow fails instead of silently using a release tag when any major has no matching branch. ## Edge Cases diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1e187ef..458e3fc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,8 +6,8 @@ name: Publish Docs # latest release, by date, for each major >= 1), builds each release tag into # its own sub-path (e.g. /v1/ and /v2/) with make generate-docs, injects a # version-picker widget into each page, adds a root redirect to the default -# version, and deploys the combined site. A manual dispatch can also rebuild -# the matching major-version path from a specified branch, tag, or commit. +# version, and deploys the combined site. A manual dispatch can build every +# major-version path from release tags or current source branches. # Triggers on release publish and manual dispatch. on: @@ -15,10 +15,14 @@ on: types: [published] workflow_dispatch: inputs: - docs_ref: - description: Branch, tag, or commit whose docs should refresh its matching major version - required: false - type: string + docs_source: + description: Source for every published major-version path + required: true + default: release-tags + type: choice + options: + - release-tags + - latest-branches # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: @@ -86,7 +90,7 @@ jobs: - name: Build all versions shell: bash env: - CONTENT_REF: ${{ inputs.docs_ref }} + USE_SOURCE_BRANCHES: ${{ inputs.docs_source == 'latest-branches' }} run: | set -euo pipefail ROOT="$PWD" @@ -96,28 +100,43 @@ jobs: # Orchestration (discovered docs-versions.json, scripts, picker assets) # always comes from THIS branch. Normally each version's HTML is produced - # by its release tag. A manual docs_ref replaces the matching major's - # HTML with content built from that ref, allowing content-only refreshes - # without minting a product release. - git fetch --tags --force origin - CONTENT_REF="${CONTENT_REF:-}" - CONTENT_WORKTREE="" - CONTENT_SLUG="" - if [[ -n "$CONTENT_REF" ]]; then - CONTENT_WORKTREE="$ROOT/../work-content" - git worktree add --force --detach "$CONTENT_WORKTREE" "$CONTENT_REF" - CONTENT_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$CONTENT_WORKTREE/src/Directory.Build.props")" - if ! node "$ROOT/scripts/list-versions.mjs" | cut -f1 | grep -Fxq "$CONTENT_SLUG"; then - echo "::error::The docs ref '$CONTENT_REF' has major '$CONTENT_SLUG', which has no published release." - exit 1 - fi - echo "Refreshing $CONTENT_SLUG docs from $CONTENT_REF" + # by its release tag. A manual dispatch can instead build every major from + # its current source branch. + git fetch --tags --force --prune origin "+refs/heads/*:refs/remotes/origin/*" + USE_SOURCE_BRANCHES="${USE_SOURCE_BRANCHES:-false}" + + MAIN_SLUG="" + if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then + MAIN_PROPS="$(mktemp)" + git show refs/remotes/origin/main:src/Directory.Build.props > "$MAIN_PROPS" + MAIN_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$MAIN_PROPS")" + rm -f "$MAIN_PROPS" + echo "Refreshing all published docs from source branches (main serves $MAIN_SLUG)" + else + echo "Refreshing all published docs from release tags" fi while IFS=$'\t' read -r slug ref; do - if [[ "$slug" == "$CONTENT_SLUG" ]]; then - echo "::group::Build $slug (from ref $CONTENT_REF)" - wt="$CONTENT_WORKTREE" + if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then + source_branch="release/${slug#v}.x" + if ! git show-ref --verify --quiet "refs/remotes/origin/$source_branch"; then + if [[ "$slug" == "$MAIN_SLUG" ]]; then + source_branch="main" + else + echo "::error::No source branch found for $slug. Expected release/${slug#v}.x, or main with VersionPrefix for $slug." + exit 1 + fi + fi + + echo "::group::Build $slug (from branch $source_branch)" + wt="$ROOT/../work-$slug" + git worktree add --force --detach "$wt" "refs/remotes/origin/$source_branch" + source_slug="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$wt/src/Directory.Build.props")" + if [[ "$source_slug" != "$slug" ]]; then + git worktree remove --force "$wt" + echo "::error::Source branch '$source_branch' has major '$source_slug', not '$slug'." + exit 1 + fi else echo "::group::Build $slug (from tag $ref)" wt="$ROOT/../work-$slug" @@ -130,16 +149,10 @@ jobs: cp -a "$wt/artifacts/_site/." "$COMBINED/$slug/" node "$ROOT/scripts/inject-version-picker.mjs" "$COMBINED/$slug" "$slug" --base / - if [[ "$wt" != "$CONTENT_WORKTREE" ]]; then - git worktree remove --force "$wt" - fi + git worktree remove --force "$wt" echo "::endgroup::" done < <(node "$ROOT/scripts/list-versions.mjs") - if [[ -n "$CONTENT_WORKTREE" ]]; then - git worktree remove --force "$CONTENT_WORKTREE" - fi - node "$ROOT/scripts/finalize-docs-site.mjs" "$COMBINED" echo "Combined site contents:" From df11d7da3bffbbed6f7e95ba7894c79384a44ca3 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Thu, 3 Sep 2026 16:57:31 -0700 Subject: [PATCH 4/8] Fix HTTP fallback error handling (#1791) --- .../AutoDetectingClientSessionTransport.cs | 19 +++--- .../Client/McpClientImpl.cs | 8 --- .../StreamableHttpClientSessionTransport.cs | 12 ++-- .../July2026ProtocolHttpFallbackTests.cs | 68 ++++++++++++++++++- .../Client/July2026ProtocolFallbackTests.cs | 63 +++++++++++++++++ 5 files changed, 146 insertions(+), 24 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index c9e821a..b3041ec 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -77,16 +77,19 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) { // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server. - // It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion, - // -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other - // application-level error). Don't fall back to SSE — that would mask the real signal - // and surface a misleading "session id required" error from the SSE GET path. - // Adopt the Streamable HTTP transport and throw the structured exception so the - // connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport - // first makes the catch filter below leave the now-owned transport alone. + // Adopt it before surfacing the failure so the catch filter leaves the now-owned + // transport alone, and never mask the response by attempting deprecated SSE. LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; - throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + + if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) + { + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } + + // TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response + // body and status without consuming the network stream a second time. + throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); } else { diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index a62ad0e..d1f2a9d 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -367,14 +367,6 @@ internal sealed partial class McpClientImpl : McpClient // fallback): falling back to initialize wouldn't fix a malformed envelope. throw; } - catch (McpProtocolException ex) when ( - ex.ErrorCode == McpErrorCode.InvalidRequest && - ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal)) - { - // Local transport validation: a 2026-07-28+ response must not carry HTTP session state. - // This is not evidence of an initialize-handshake server, so do not fall back. - throw; - } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index db67b2e..5b29449 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -80,8 +80,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && - (response.StatusCode == HttpStatusCode.BadRequest || - IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) + ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -89,10 +88,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) => - code is McpErrorCode.UnsupportedProtocolVersion - or McpErrorCode.MissingRequiredClientCapability - or McpErrorCode.HeaderMismatch; + internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) => + statusCode == HttpStatusCode.BadRequest || + (McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; /// /// Reads a JSON-RPC error envelope from an application/json response body, returning diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 599bdd9..9126331 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -57,7 +57,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : base.Dispose(); } - private async Task StartServerAsync(RequestDelegate handler) + private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false) { Builder.Services.Configure(options => { @@ -65,7 +65,14 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : }); _app = Builder.Build(); - _app.MapPost("/mcp", handler); + if (acceptGet) + { + _app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler); + } + else + { + _app.MapPost("/mcp", handler); + } await _app.StartAsync(TestContext.Current.CancellationToken); } @@ -302,6 +309,63 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : Assert.False(initializeReceived); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode) + { + var ct = TestContext.Current.CancellationToken; + var initializeRequests = 0; + var sseGetRequests = 0; + + await StartServerAsync(async context => + { + if (HttpMethods.IsGet(context.Request.Method)) + { + sseGetRequests++; + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) + { + initializeRequests++; + } + + await WriteJsonRpcErrorAsync( + context, + statusCode, + code: (int)McpErrorCode.InvalidRequest, + message: "non-modern structured error"); + }, acceptGet: true); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.AutoDetect, + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync( + transport, + new McpClientOptions(), + loggerFactory: LoggerFactory, + cancellationToken: ct); + }); + + Assert.Equal(statusCode, exception.StatusCode); + Assert.Contains("non-modern structured error", exception.Message); + Assert.Equal(0, initializeRequests); + Assert.Equal(0, sseGetRequests); + } + [Fact] public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() { diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index fed5df6..557dc56 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -239,6 +239,28 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } + [Theory] + [InlineData(HttpTransportMode.StreamableHttp)] + [InlineData(HttpTransportMode.AutoDetect)] + public async Task Client_OnStructuredInvalidRequestFromHttpProbe_FallsBackTo_Initialize( + HttpTransportMode transportMode) + { + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateStructuredInvalidRequestProbeServer( + () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(initializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + } + [Theory] [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] @@ -317,6 +339,47 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : } }; + private static Func> CreateStructuredInvalidRequestProbeServer( + Action onInitialize) + => async request => + { + if (request.Method == HttpMethod.Get) + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("method", out var methodElement)) + return EmptyResponse(HttpStatusCode.Accepted); + + if (methodElement.GetString() == RequestMethods.ServerDiscover) + { + var id = doc.RootElement.GetProperty("id").GetRawText(); + var error = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"error\":{\"code\":-32600,\"message\":\"Mcp-Session-Id header is required\"}}"; + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(error, Encoding.UTF8, "application/json"), + }; + } + + if (methodElement.GetString() == RequestMethods.Initialize) + { + onInitialize(); + var id = doc.RootElement.GetProperty("id").GetRawText(); + var result = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"result\":{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + + "\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(result, Encoding.UTF8, "application/json"), + }; + response.Headers.Add("mcp-session-id", "test-session"); + return response; + } + + return EmptyResponse(HttpStatusCode.Accepted); + }; + private static HttpResponseMessage EmptyResponse(HttpStatusCode status) => new(status) { Content = new StringContent(string.Empty) }; From cb3e6e87fe7d79fd6c324a06f2ad4743e5becf94 Mon Sep 17 00:00:00 2001 From: King Star Date: Thu, 10 Sep 2026 03:25:54 +0800 Subject: [PATCH 5/8] fix(aspnetcore): make SEP-2575 status mapping deterministic (#1837) --- .../Server/StreamableHttpPostTransport.cs | 86 +++---------------- .../Server/StreamableHttpServerTransport.cs | 14 +-- .../MapMcpTests.cs | 13 +-- .../RawHttpConformanceTests.cs | 32 +++++++ 4 files changed, 60 insertions(+), 85 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index 95411f7..5b105c3 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -76,9 +76,6 @@ internal sealed partial class StreamableHttpPostTransport( return false; } - CancellationTokenSource? deferredFlushCts = null; - Task? deferredFlushTask = null; - bool deferHeaderFlush = false; using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false)) { var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false); @@ -87,88 +84,33 @@ internal sealed partial class StreamableHttpPostTransport( await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false); } - else if (onResponseStarting is null) + else if (onResponseStarting is not null && + McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion)) { - // If there's no priming write, flush the stream to ensure HTTP response headers are - // sent to the client now that the server is ready to process the request. - // This prevents HttpClient timeout for long-running requests. - await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + // Per-request-metadata protocol revisions map the first JSON-RPC error onto the HTTP + // status line. Keep the headers uncommitted until that message arrives so the mapping + // cannot depend on how long dispatch takes. } else { - deferHeaderFlush = true; + // Earlier protocol revisions keep the eager header flush for long-running handlers. + // Mark the response as started before flushing because any later JSON-RPC error must + // not attempt to change an already committed status line. + await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); + await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } // Ensure that we've sent the priming event before processing the incoming request. await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false); } - if (deferHeaderFlush) - { - // Defer the flush (and the header commit it implies) so the callback can still choose - // the HTTP status line for an immediate JSON-RPC error. Start the bounded grace period - // only after the request has been queued for dispatch. - deferredFlushCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - deferredFlushTask = DeferredHeaderFlushAsync(deferredFlushCts.Token); - } - - try - { - // Wait for the response to be written before returning from the handler. - // This keeps the HTTP response open until the final response message is sent. - await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - if (deferredFlushCts is not null) - { - deferredFlushCts.Cancel(); - await deferredFlushTask!.ConfigureAwait(false); - deferredFlushCts.Dispose(); - } - } + // Wait for the response to be written before returning from the handler. + // This keeps the HTTP response open until the final response message is sent. + await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); return true; } - /// - /// Bounds the deferred header flush: after a short grace window, flushes the response headers - /// if no response message has been written yet. Immediate rejections land well inside the - /// window, so the response-starting callback can still map their JSON-RPC error codes onto the - /// HTTP status line; a handler that runs longer commits the headers here so clients see them - /// promptly (long-running tool calls must not trip HttpClient's response timeout). - /// - private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken) - { - try - { - await Task.Delay(DeferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false); - using var _ = await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false); - if (!_httpResponseStarted && !_httpResponseCompleted) - { - await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); - await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // The response was written or the request ended before the grace window elapsed. - } - catch (Exception ex) - { - // Surface the failure to the awaiting HandlePostAsync when possible. If the response - // future has already been resolved (the response started or completed on another path), - // TrySetException is a no-op, so log here to keep the deferred-flush failure diagnosable. - if (!_httpResponseTcs.TrySetException(ex)) - { - LogDeferredHeaderFlushFailed(ex); - } - } - } - - /// How long the response-header flush may be deferred waiting for the first response message. - internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250); - /// /// Invokes the response-starting callback exactly once, immediately before the first write to /// the HTTP response stream, so the HTTP application can still set the response status line. @@ -343,6 +285,4 @@ internal sealed partial class StreamableHttpPostTransport( [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")] private partial void LogStoreStreamDisposalFailed(Exception exception); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to flush deferred Streamable HTTP response headers.")] - private partial void LogDeferredHeaderFlushFailed(Exception exception); } diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index f143eaa..7f9a195 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -216,14 +216,14 @@ public sealed partial class StreamableHttpServerTransport : ITransport /// This overload additionally reports the first JSON-RPC message written to the response via /// , before any response bytes are written, so the HTTP /// application can still choose the response status line (SEP-2575 maps some JSON-RPC error - /// codes to HTTP statuses). When is provided, the eager - /// response-header flush that normally precedes request processing is deferred until that first - /// message; the callback receives when the first write is not a JSON-RPC - /// message (e.g. a resumability priming event). + /// codes to HTTP statuses). When is provided for a + /// per-request-metadata protocol revision, the eager response-header flush that normally precedes + /// request processing is deferred until that first message; the callback receives + /// when the first write is not a JSON-RPC message (e.g. a resumability + /// priming event). /// The status line can only be influenced by the FIRST write: when a handler streams a - /// notification (e.g. progress) before failing, or runs past the transport's bounded - /// header-flush grace window, the status is already committed and a later JSON-RPC error - /// rides the committed status. + /// notification (e.g. progress) before failing, the status is already committed and a later + /// JSON-RPC error rides the committed status. /// /// The JSON-RPC message to process. /// The response stream to write any JSON-RPC responses to. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index ef68321..43a3c12 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -239,10 +239,9 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : [Fact] public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() { - // Regression test for: Tool calls that last over HttpClient timeout without producing - // intermediate notifications will timeout because HttpClient doesn't see the 200 response - // until the first message is written. When primingItem is null (no ISseEventStreamStore), - // we should flush the response stream so HttpClient sees the 200 immediately. + // Legacy Streamable HTTP tool calls that run past the HttpClient timeout without producing + // intermediate notifications need an eager response flush. Per-request-metadata protocol + // revisions instead wait for the first JSON-RPC message so SEP-2575 can map its status. Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -274,7 +273,11 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : TransportMode = transportMode, }, shortTimeoutClient, LoggerFactory); - await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var mcpClient = await McpClient.CreateAsync( + transport, + new McpClientOptions { ProtocolVersion = "2025-11-25" }, + LoggerFactory, + TestContext.Current.CancellationToken); // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout // because the response stream is flushed immediately after receiving the request diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 0fce6dc..b57852f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -207,6 +207,27 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); } + [Fact] + public async Task July2026Post_SlowHandler_MissingRequiredCapability_Returns400() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":20,""method"":""tools/call"",""params"":{""name"":""slow_requires_sampling"",""arguments"":{}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "slow_requires_sampling"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(20, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); + } + [Fact] public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() { @@ -500,10 +521,21 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn [McpServerToolType] private sealed class CapabilityTools { + private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1); + [McpServerTool(Name = "requires_sampling")] public static string RequiresSampling() => throw new MissingRequiredClientCapabilityException( new ClientCapabilities { Sampling = new() }, "sampling capability required but not declared by client"); + + [McpServerTool(Name = "slow_requires_sampling")] + public static async Task SlowRequiresSampling(CancellationToken cancellationToken) + { + await Task.Delay(SlowHandlerDelay, cancellationToken); + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + } } } From 6001d5ac7ab6a63fa4d0eb79c7fb6b5d5d2bc30d Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sat, 12 Sep 2026 14:12:28 -0700 Subject: [PATCH 6/8] Update SourceLink to resolve vulnerable Git build dependency (#1873) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 69ed858..565d016 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -58,7 +58,7 @@ - + From 324ccd83c357acf611e1cf3a6935a945ee600442 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sun, 13 Sep 2026 16:30:42 -0700 Subject: [PATCH 7/8] Relax legacy request metadata validation (#1852) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 18 +- .../McpSessionHandler.cs | 67 --- .../Protocol/JsonRpcMessageContext.cs | 20 +- .../Server/DestinationBoundMcpServer.cs | 12 +- .../Server/McpServer.Methods.cs | 26 +- .../Server/McpServer.cs | 7 +- .../Server/McpServerImpl.cs | 412 ++++++++++-------- .../OutgoingRequestInterceptingMcpServer.cs | 5 + .../RawHttpConformanceTests.cs | 132 +++++- .../StatelessServerTests.cs | 93 +++- .../Client/McpClientMetaTests.cs | 51 ++- .../Server/McpServerTests.cs | 72 ++- .../Server/NegotiatedProtocolVersionTests.cs | 216 ++++++++- .../Server/RawStreamConformanceTests.cs | 36 +- .../Server/TaskProtocolGatingTests.cs | 14 +- 15 files changed, 785 insertions(+), 396 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 50c20a7..f26e9ed 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -846,23 +846,17 @@ internal sealed class StreamableHttpHandler( return false; } - bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); - - if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && - !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta)) + // Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve + // the version from the session or metadata, falling back to legacy handling when neither selects one. + if (string.IsNullOrEmpty(protocolVersionHeader) || + McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader)) { errorDetail = null; return true; } - if (string.IsNullOrEmpty(protocolVersionHeader)) - { - errorDetail = CreateHeaderMismatchError( - $"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version."); - return false; - } - - if (!hasProtocolVersionMeta) + // Header validity was checked first, so only supported modern headers reach this point. + if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta)) { // Notifications are exempt from the required-_meta rejection: they are fire-and-forget // (a JSON-RPC error response has no recipient), and rejecting them silently drops diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 92b42de..d2b0745 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -401,14 +401,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { - // Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any - // filters run so they (and downstream handlers) can read client info / capabilities / - // protocol version / log level without re-parsing. - if (_isServer && message is JsonRpcRequest incomingRequest) - { - PopulateContextFromMeta(incomingRequest); - } - Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); @@ -544,65 +536,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable return result; } - /// - /// Reads the 2026-07-28 protocol's per-request _meta fields off the request and projects them onto - /// so they're available without re-parsing throughout the pipeline. - /// - /// - /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, - /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field - /// that's already set on the context (e.g., - /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone - /// unless explicitly overwritten by a non-null value parsed here. - /// - internal static void PopulateContextFromMeta(JsonRpcRequest request) - { - if (request.Params is not JsonObject paramsObj) - { - return; - } - - if (paramsObj["_meta"] is not JsonObject metaObj) - { - return; - } - - var context = request.Context ??= new JsonRpcMessageContext(); - - if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion && - protocolVersion.TryGetValue(out string? protocolVersionValue)) - { - // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already - // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with - // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), - // which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather - // than mistaking it for an initialize-handshake server and falling back to initialize. - if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) - { - throw new McpProtocolException( - $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", - McpErrorCode.HeaderMismatch); - } - - context.ProtocolVersion = protocolVersionValue; - } - - if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) - { - context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); - } - - if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) - { - context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); - } - - if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) - { - context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); - } - } - /// /// Injects the 2026-07-28 protocol's per-request _meta fields into an outgoing request. /// Protocol version and client info overwrite any existing values; client capabilities are merged diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index def9b89..0b56caa 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -88,13 +88,12 @@ public sealed class JsonRpcMessageContext public string? RoutingName { get; set; } /// - /// Gets or sets the protocol version from the transport-level header (e.g. Mcp-Protocol-Version) - /// that accompanied this JSON-RPC message. + /// Gets or sets the authoritative protocol version for this JSON-RPC message. /// /// - /// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the initialize - /// handshake because each request creates a new server instance. This property allows the transport layer - /// to flow the protocol version header so the server can determine client capabilities. + /// The transport may populate this from a header such as Mcp-Protocol-Version. For modern revisions, + /// the server validates and projects the matching per-request _meta value. Under an established legacy + /// revision, future reserved metadata remains opaque and does not establish or change the negotiated session version. /// public string? ProtocolVersion { get; set; } @@ -104,7 +103,8 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). When the request was made under the 2026-07-28 or later revision, - /// the server uses this in lieu of the value previously captured during the initialize handshake. + /// the server uses this in lieu of the value previously captured during the initialize handshake. Future reserved + /// metadata remains opaque under legacy revisions, which continue to use their initialized identity. /// public Implementation? ClientInfo { get; set; } @@ -114,7 +114,9 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client - /// capabilities from previous requests; the authoritative value is the one declared on each request. + /// capabilities from previous modern requests; the authoritative value is the one declared on each request. + /// Future reserved metadata remains opaque under legacy revisions, which continue to use the capabilities + /// negotiated during initialization. /// public ClientCapabilities? ClientCapabilities { get; set; } @@ -124,8 +126,8 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Replaces the legacy - /// RPC. When absent, the server MUST NOT emit log notifications - /// for the request. + /// RPC. When absent from a modern request, the server MUST NOT emit + /// log notifications for the request. Legacy requests continue to use their negotiated logging behavior. /// public LoggingLevel? LogLevel { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 7aab348..06a8777 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -23,14 +23,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport { get { - // In stateless transport mode, a single request does not have a persistent bidirectional channel. - // Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the - // capability gates rely on a null ClientCapabilities value to report that unsupported-state path. - if (!server.HasStatefulTransport()) - { - return null; - } - // On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request) // and must not be inferred from prior requests. Missing per-request capabilities therefore means // "no declared capabilities for this request", represented by an empty object. A fresh instance is @@ -42,11 +34,13 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport } // Legacy protocol behavior uses session-scoped capabilities established during initialize (or - // pre-populated migration data), so ignore per-request values and return the server session state. + // pre-populated migration data). Future reserved metadata remains opaque under legacy semantics. return server.ClientCapabilities; } } + internal override bool SupportsServerToClientRequests => server.SupportsServerToClientRequests; + public override Implementation? ClientInfo { get diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index a9a5ddd..2e51f51 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -25,6 +25,8 @@ public abstract partial class McpServer : McpSession internal virtual Func>? OutgoingRequestInterceptor => null; + internal virtual bool SupportsServerToClientRequests => true; + /// /// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor. /// @@ -555,26 +557,26 @@ public abstract partial class McpServer : McpSession private void ThrowIfSamplingUnsupported() { + if (!SupportsServerToClientRequests) + { + throw new InvalidOperationException("Sampling is not supported in stateless mode."); + } + if (ClientCapabilities?.Sampling is null) { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Sampling is not supported in stateless mode."); - } - throw new InvalidOperationException("Client does not support sampling."); } } private void ThrowIfRootsUnsupported() { + if (!SupportsServerToClientRequests) + { + throw new InvalidOperationException("Roots are not supported in stateless mode."); + } + if (ClientCapabilities?.Roots is null) { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Roots are not supported in stateless mode."); - } - throw new InvalidOperationException("Client does not support roots."); } } @@ -615,12 +617,12 @@ public abstract partial class McpServer : McpSession private void ThrowIfElicitationUnsupported(ElicitRequestParams request) { - if (ClientCapabilities is null) + if (!SupportsServerToClientRequests) { throw new InvalidOperationException("Elicitation is not supported in stateless mode."); } - var elicitationCapability = ClientCapabilities.Elicitation; + var elicitationCapability = ClientCapabilities?.Elicitation; if (elicitationCapability is null) { throw new InvalidOperationException("Client does not support elicitation requests."); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 4797ce1..29f524a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -32,12 +32,11 @@ public abstract partial class McpServer : McpSession /// the Server property of the passed to a handler; on the /// root (for example one constructed manually over a /// ) it is . - /// It is also in stateless transport mode, where server-to-client requests are - /// unsupported. /// /// - /// Server implementations can check these capabilities to determine which features - /// are available when interacting with the client. + /// This property reports capabilities declared by the client. Their presence does not guarantee that the + /// transport supports server-to-client requests. Methods such as sampling, roots, and elicitation reject + /// those requests when the transport cannot safely deliver them, including in stateless mode. /// /// public abstract ClientCapabilities? ClientCapabilities { get; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2ce8387..76a65ed 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -33,14 +33,6 @@ internal sealed partial class McpServerImpl : McpServer private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); - private static readonly string[] s_perRequestMetadataKeys = - [ - MetaKeys.ProtocolVersion, - MetaKeys.ClientInfo, - MetaKeys.ClientCapabilities, - MetaKeys.LogLevel, - ]; - // Track MRTR handler tasks using the same inFlightCount + TCS pattern as // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. private int _mrtrInFlightCount = 1; @@ -122,7 +114,7 @@ internal sealed partial class McpServerImpl : McpServer // 2026-07-28+ client) is decided per response in GetAdvertisedCapabilities rather than cleared here, // because the same ServerCapabilities feeds both the legacy initialize handshake (which can never // deliver it) and server/discover (which can, given a custom handler). - if (HasStatefulTransport()) + if (HasStatefulTransport) { Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification); Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification); @@ -160,124 +152,46 @@ internal sealed partial class McpServerImpl : McpServer /// /// Wraps so that, for every JSON-RPC request, a built-in filter first - /// synchronizes server-side state (, ) - /// from the per-request _meta values projected onto and - /// validates the per-request protocol version, before delegating to the user-supplied incoming filters. + /// classifies and projects modern per-request _meta, synchronizes server-side state + /// (, ), and validates protocol + /// boundaries before delegating to user-supplied incoming filters. /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so the protocol /// version and client capabilities MUST be populated per-request. Client info is optional. Per-request client /// capabilities and client info are consumed request-scoped by and are /// not read from server-wide state by request handlers. The shared write below is - /// best-effort and used only to derive the session endpoint name for logging/telemetry. For initialize-handshake - /// clients the per-request values are absent and the built-in filter is a no-op (the values were captured during - /// the initialize handler). + /// best-effort and used only to derive the session endpoint name for logging/telemetry. Under initialize-handshake + /// revisions, reserved per-request metadata is opaque and is not parsed or projected. Modern envelopes remain + /// strictly parsed. /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) => { - if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest) + if (message is JsonRpcRequest request) { - ValidateInitializeRequestBoundary(initializeRequest); - } - else if (message is JsonRpcRequest request) - { - var context = request.Context; - bool endpointNameNeedsRefresh = false; - bool hasProtocolVersionMeta = HasMetaKey(request, MetaKeys.ProtocolVersion); - bool hasReservedPerRequestMeta = TryGetPerRequestMetadataKey(request, out var reservedPerRequestMetaKey); - - if (context?.ProtocolVersion is { } protocolVersion) + if (request.Method == RequestMethods.Initialize) { - bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; - if (protocolVersionAlreadyEstablished) - { - SetNegotiatedProtocolVersion(protocolVersion); - } - - // Per SEP-2575, the server MUST reject any request whose per-request - // _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions - // with an UnsupportedProtocolVersionError (-32022) carrying the supported list. - if (!_supportedProtocolVersions.Contains(protocolVersion)) - { - var supportedVersions = - hasProtocolVersionMeta && _perRequestMetadataProtocolVersions.Length > 0 ? - _perRequestMetadataProtocolVersions : - _supportedProtocolVersions; - - throw new UnsupportedProtocolVersionException( - requested: protocolVersion, - supported: supportedVersions); - } - - if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) - { - ValidateRequiredPerRequestMetadata( - protocolVersion, - hasProtocolVersionMeta, - context.ClientCapabilities is not null); - } - else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) - { - if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta) - { - throw new UnsupportedProtocolVersionException( - requested: protocolVersion, - supported: _perRequestMetadataProtocolVersions, - message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata."); - } - - if (hasReservedPerRequestMeta) - { - ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey); - } - } - - if (!protocolVersionAlreadyEstablished) - { - SetNegotiatedProtocolVersion(protocolVersion); - } + ValidateInitializeRequestBoundary(request); } - else if (_negotiatedProtocolVersion is null) + else { - if (request.Method == RequestMethods.ServerDiscover) + ReadRequestMetadata(request); + ValidateRequestMethodBoundary(request); + + var context = request.Context; + if (McpProtocolVersions.RequiresPerRequestMetadata(context?.ProtocolVersion ?? _negotiatedProtocolVersion) && + context?.ClientInfo is { } clientInfo && + (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || + !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { - throw new McpProtocolException( - $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", - McpErrorCode.InvalidParams); + // Modern handlers resolve client info request-scoped through DestinationBoundMcpServer. + // This shared write is only for endpoint logging. + _clientInfo = clientInfo; + UpdateEndpointNameWithClientInfo(); + _sessionHandler.EndpointName = _endpointName; } - - if (hasReservedPerRequestMeta) - { - ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey); - } - } - else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) - { - ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey); - } - - ValidateRequestMethodBoundary(request); - - if (context?.ClientInfo is { } clientInfo && - (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || - !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) - { - // This shared write is best-effort and used only to derive the session endpoint name for - // logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions: - // DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from - // the per-request _meta so concurrent requests never observe each other's values. Under a - // draft stateful session with differing per-request client info, the last writer wins here, - // which only affects the logged endpoint name and never the request-scoped values handlers see. - _clientInfo = clientInfo; - endpointNameNeedsRefresh = true; - } - - if (endpointNameNeedsRefresh) - { - UpdateEndpointNameWithClientInfo(); - _sessionHandler.EndpointName = _endpointName; } } else if (message is JsonRpcNotification notification) @@ -291,56 +205,158 @@ internal sealed partial class McpServerImpl : McpServer return next => metaReadingFilter(inner(next)); } - private static void ValidateRequiredPerRequestMetadata( - string protocolVersion, - bool hasProtocolVersionMeta, - bool hasClientCapabilitiesMeta) + private void ReadRequestMetadata(JsonRpcRequest request) { - if (!hasProtocolVersionMeta) + string? transportProtocolVersion = request.Context?.ProtocolVersion; + + // An established legacy session or supported legacy transport header selects initialize-handshake + // semantics. Continue validating an authoritative transport version, but treat future body metadata + // as opaque under the negotiated legacy revision. + if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) || + McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion)) { - ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion); + if (transportProtocolVersion is not null) + { + if (!_supportedProtocolVersions.Contains(transportProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: transportProtocolVersion, + supported: _supportedProtocolVersions); + } + + SetNegotiatedProtocolVersion(transportProtocolVersion); + } + + return; } - // clientInfo is optional: requests whose _meta omits it are served, not rejected. + JsonObject? meta = request.Params is JsonObject paramsObj ? paramsObj["_meta"] as JsonObject : null; + // An unreadable value cannot select a protocol; only the modern path requires a usable version. + string? metadataProtocolVersion = + meta?[MetaKeys.ProtocolVersion] is JsonValue value && value.TryGetValue(out string? version) ? version : null; - if (!hasClientCapabilitiesMeta) + if (transportProtocolVersion is not null && + metadataProtocolVersion is not null && + !string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal)) { - ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + throw new McpProtocolException( + $"Header mismatch: the per-request _meta protocol version '{metadataProtocolVersion}' does not match the MCP-Protocol-Version header value '{transportProtocolVersion}'.", + McpErrorCode.HeaderMismatch); + } + + if (McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) || + transportProtocolVersion is not null || + (metadataProtocolVersion is not null && !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion)) || + _initializeHandshakeProtocolVersions.Length == 0) + { + if (metadataProtocolVersion is null) + { + if (transportProtocolVersion is not null && + !_supportedProtocolVersions.Contains(transportProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: transportProtocolVersion, + supported: _supportedProtocolVersions); + } + + throw MissingPerRequestMetadata( + transportProtocolVersion ?? _negotiatedProtocolVersion ?? _perRequestMetadataProtocolVersions[0], + MetaKeys.ProtocolVersion); + } + + if (!_supportedProtocolVersions.Contains(metadataProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: metadataProtocolVersion, + supported: _perRequestMetadataProtocolVersions.Length > 0 + ? _perRequestMetadataProtocolVersions + : _supportedProtocolVersions); + } + + // Reject version changes before parsing, but establish a new version only after parsing succeeds. + bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; + if (protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(metadataProtocolVersion); + } + + ProjectModernMetadata(request, meta!, metadataProtocolVersion); + + if (!protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(metadataProtocolVersion); + } + + return; + } + + if (request.Method == RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", + McpErrorCode.InvalidParams); + } + + // With no established era, warn in case a modern peer accidentally fell back to legacy handling. + if (metadataProtocolVersion is null && meta?.ContainsKey(MetaKeys.ProtocolVersion) is true) + { + LogIgnoredUnreadableProtocolVersionMetadata(_endpointName, request.Method); } } - private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) => - throw new McpProtocolException( + private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta, string protocolVersion) + { + if (!meta.ContainsKey(MetaKeys.ClientCapabilities)) + { + throw MissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + } + + var context = request.Context ??= new(); + context.ProtocolVersion = protocolVersion; + context.ClientInfo = meta[MetaKeys.ClientInfo] is JsonNode clientInfoNode + ? DeserializeModernMetadata( + clientInfoNode, + McpJsonUtilities.JsonContext.Default.Implementation, + MetaKeys.ClientInfo) + : null; + context.ClientCapabilities = meta[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode + ? DeserializeModernMetadata( + clientCapabilitiesNode, + McpJsonUtilities.JsonContext.Default.ClientCapabilities, + MetaKeys.ClientCapabilities) + : throw InvalidMetadata(MetaKeys.ClientCapabilities); + context.LogLevel = meta[MetaKeys.LogLevel] is JsonNode logLevelNode + ? DeserializeModernMetadata( + logLevelNode, + McpJsonUtilities.JsonContext.Default.LoggingLevel, + MetaKeys.LogLevel) + : null; + } + + private static T DeserializeModernMetadata(JsonNode node, JsonTypeInfo typeInfo, string key) + { + try + { + T? value = JsonSerializer.Deserialize(node, typeInfo); + return value is not null ? value : throw new JsonException(); + } + catch (JsonException ex) + { + throw new McpProtocolException( + $"The per-request metadata key '_meta/{key}' has an invalid value.", + ex, + McpErrorCode.InvalidParams); + } + } + + private static McpProtocolException InvalidMetadata(string key) => + new($"The per-request metadata key '_meta/{key}' has an invalid value.", McpErrorCode.InvalidParams); + + private static McpProtocolException MissingPerRequestMetadata(string protocolVersion, string key) => + new( $"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.", McpErrorCode.InvalidParams); - private static void ThrowReservedPerRequestMetadata(string? requestedProtocolVersion, string key) => - throw new McpProtocolException( - requestedProtocolVersion is null - ? $"The reserved per-request metadata key '_meta/{key}' requires a protocol version that uses per-request metadata." - : $"The reserved per-request metadata key '_meta/{key}' is not valid with protocol version '{requestedProtocolVersion}'.", - McpErrorCode.InvalidRequest); - - private static bool TryGetPerRequestMetadataKey(JsonRpcRequest request, out string key) - { - foreach (var candidate in s_perRequestMetadataKeys) - { - if (HasMetaKey(request, candidate)) - { - key = candidate; - return true; - } - } - - key = ""; - return false; - } - - private static bool HasMetaKey(JsonRpcRequest request, string key) => - request.Params is JsonObject paramsObj && - paramsObj["_meta"] is JsonObject metaObj && - metaObj.ContainsKey(key); - /// /// Adds the server identity to every successful result on per-request-metadata protocol revisions. /// The filter runs before application filters so they can inspect or intentionally remove the metadata. @@ -372,12 +388,17 @@ internal sealed partial class McpServerImpl : McpServer private void ValidateInitializeRequestBoundary(JsonRpcRequest request) { - // Per-request-metadata revisions (SEP-2575) removed the initialize handshake entirely: - // the request is for a method the server does not implement on that revision. - if (McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion)) + // Modern revisions removed initialize. An established modern session is authoritative even when + // the new request has no version header; a failed discovery probe does not establish a version. + string? modernProtocolVersion = + McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion) ? request.Context!.ProtocolVersion : + McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ? _negotiatedProtocolVersion : + null; + + if (modernProtocolVersion is not null) { throw new McpProtocolException( - $"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.", + $"Method '{RequestMethods.Initialize}' is not available on protocol version '{modernProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.", McpErrorCode.MethodNotFound); } @@ -389,23 +410,6 @@ internal sealed partial class McpServerImpl : McpServer supported: _initializeHandshakeProtocolVersions, message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake."); } - - if (TryGetPerRequestMetadataKey(request, out var key)) - { - ThrowReservedPerRequestMetadata(TryGetStringParam(request, "protocolVersion"), key); - } - } - - private static string? TryGetStringParam(JsonRpcRequest request, string propertyName) - { - if (request.Params is JsonObject paramsObj && - paramsObj[propertyName] is JsonValue value && - value.TryGetValue(out string? result)) - { - return result; - } - - return null; } private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) @@ -511,13 +515,17 @@ internal sealed partial class McpServerImpl : McpServer public ServerCapabilities ServerCapabilities { get; } /// - /// Returns the to advertise in a specific response, suppressing the - /// listChanged flags the server has no way to honor. + /// Returns the to advertise in a specific response, suppressing + /// capabilities that are not available on that response's protocol path. /// /// /// when the client this response targets can receive */list_changed /// notifications over a subscriptions/listen stream. /// + /// + /// for legacy initialize responses that support logging/setLevel; + /// for modern discover responses, where that method is unavailable. + /// /// /// A stateless HTTP server has no session-wide channel to push unsolicited */list_changed /// notifications. It can only deliver them over a subscriptions/listen stream, which requires both @@ -525,11 +533,15 @@ internal sealed partial class McpServerImpl : McpServer /// to own that stream (the built-in stateless /// handler grants no notifications). When neither the transport is stateful nor that stream can carry /// them, the listChanged flags are dropped so the server never advertises a capability it cannot - /// deliver. Everything else (for example resources.subscribe) is preserved. + /// deliver. The deprecated logging capability is likewise omitted from modern discovery because this SDK + /// rejects the legacy logging/setLevel method on that path. Everything else is preserved. /// - private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged) + private ServerCapabilities GetAdvertisedCapabilities( + bool listenStreamCanDeliverListChanged, + bool includeDeprecatedLogging) { - if (HasStatefulTransport() || listenStreamCanDeliverListChanged) + bool includeListChanged = HasStatefulTransport || listenStreamCanDeliverListChanged; + if (includeListChanged && includeDeprecatedLogging) { return ServerCapabilities; } @@ -539,20 +551,32 @@ internal sealed partial class McpServerImpl : McpServer return new ServerCapabilities { Experimental = ServerCapabilities.Experimental, - Logging = ServerCapabilities.Logging, + Logging = includeDeprecatedLogging ? ServerCapabilities.Logging : null, Completions = ServerCapabilities.Completions, Extensions = ServerCapabilities.Extensions, - Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null }, + Prompts = ServerCapabilities.Prompts is null + ? null + : includeListChanged + ? ServerCapabilities.Prompts + : new PromptsCapability { ListChanged = null }, Resources = ServerCapabilities.Resources is { } resources - ? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null } + ? includeListChanged + ? resources + : new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null } : null, - Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null }, + Tools = ServerCapabilities.Tools is null + ? null + : includeListChanged + ? ServerCapabilities.Tools + : new ToolsCapability { ListChanged = null }, }; } /// public override ClientCapabilities? ClientCapabilities => _clientCapabilities; + internal override bool SupportsServerToClientRequests => HasStatefulTransport; + /// public override Implementation? ClientInfo => _clientInfo; @@ -688,11 +712,8 @@ internal sealed partial class McpServerImpl : McpServer string negotiatedProtocolVersion = protocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; - // The initialize handshake is authoritative: it may supersede a protocol version - // a prior server/discover probe established on the same connection (the dual-path - // fallback path a permissive client takes against an unknown server). Unlike the - // per-request 2026-07-28 version - which SetNegotiatedProtocolVersion locks once negotiated - - // initialize force-sets the version. + // initialize may supersede a legacy transport version. ValidateInitializeRequestBoundary + // prevents it from downgrading an established modern session. _negotiatedProtocolVersion = negotiatedProtocolVersion; _sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion; @@ -705,7 +726,9 @@ internal sealed partial class McpServerImpl : McpServer // The initialize handshake only serves pre-2026-07-28 clients, which cannot open a // subscriptions/listen stream, so a stateless server has no way to deliver list-changed // notifications to them regardless of any custom handler. - Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false), + Capabilities = GetAdvertisedCapabilities( + listenStreamCanDeliverListChanged: false, + includeDeprecatedLogging: true), // resultType is a 2026-07-28 result field. The initialize handshake is only available on // 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw @@ -738,7 +761,8 @@ internal sealed partial class McpServerImpl : McpServer // author supplied a custom handler to own that stream (the built-in stateless handler // grants nothing, so it cannot). Capabilities = GetAdvertisedCapabilities( - listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null), + listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null, + includeDeprecatedLogging: false), Instructions = options.ServerInstructions, // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to // the safest values (immediately stale, not shareable) so existing servers keep @@ -841,7 +865,7 @@ internal sealed partial class McpServerImpl : McpServer // request granting no notifications and complete immediately. This runs after protocol // negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the // initialize handshake. - if (!HasStatefulTransport()) + if (!HasStatefulTransport) { var statelessSubscription = new ActiveSubscription( jsonRpcRequest.Id, @@ -2048,7 +2072,7 @@ internal sealed partial class McpServerImpl : McpServer /// sites where the intent is "the client understands " rather than /// "the peer speaks the 2026-07-28 or later revision". /// - internal bool ClientSupportsMrtr() => IsJuly2026OrLaterProtocol(); + private bool ClientSupportsMrtr => IsJuly2026OrLaterProtocol(); /// /// Returns when the session is stateful - the same server instance handles @@ -2057,8 +2081,9 @@ internal sealed partial class McpServerImpl : McpServer /// elicitation/create / sampling/createMessage / roots/list to the client and /// retry the handler with the responses. /// - internal bool HasStatefulTransport() => + private bool HasStatefulTransport => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; + /// /// Returns when the given request was negotiated under the 2026-07-28 or later protocol /// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works @@ -2073,7 +2098,7 @@ internal sealed partial class McpServerImpl : McpServer requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion); /// - public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport(); + public override bool IsMrtrSupported => ClientSupportsMrtr || HasStatefulTransport; /// /// Invokes a handler and catches to convert it to an @@ -2117,7 +2142,7 @@ internal sealed partial class McpServerImpl : McpServer // If the client natively supports MRTR, serialize and return directly - // the client will drive the retry loop. - if (ClientSupportsMrtr()) + if (ClientSupportsMrtr) { return SerializeInputRequiredResult(inputRequiredResult); } @@ -2125,7 +2150,7 @@ internal sealed partial class McpServerImpl : McpServer // In stateless mode without MRTR, the server can't resolve input requests via // JSON-RPC (no persistent session for server-to-client requests), and the client // won't recognize the InputRequiredResult. This is the one unsupported configuration. - if (!HasStatefulTransport()) + if (!HasStatefulTransport) { throw new McpException( "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + @@ -2364,7 +2389,7 @@ internal sealed partial class McpServerImpl : McpServer // For all other cases - legacy clients, stateless sessions - fall through to the // exception-based path, which transparently resolves InputRequiredException via // legacy JSON-RPC requests when the client doesn't speak MRTR. - if (!ClientSupportsMrtr() || !HasStatefulTransport()) + if (!ClientSupportsMrtr || !HasStatefulTransport) { return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); } @@ -2527,6 +2552,9 @@ internal sealed partial class McpServerImpl : McpServer [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] private partial void MrtrHandlerError(Exception exception); + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} ignored an unreadable '_meta/io.modelcontextprotocol/protocolVersion' value on '{Method}' and selected initialize-handshake semantics. The client may not be spec-compliant.")] + private partial void LogIgnoredUnreadableProtocolVersionMetadata(string endpointName, string method); + [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")] private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception); } diff --git a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs index 93b7e5e..e32a160 100644 --- a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs @@ -17,6 +17,11 @@ internal sealed class OutgoingRequestInterceptingMcpServer( public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities; + // The interceptor replaces the session's server-to-client channel, so it can deliver requests even + // when the underlying transport cannot (for example a stateless HTTP request, where a background task + // parks the request in an IMcpTaskStore and the client answers it on a later, unrelated request). + internal override bool SupportsServerToClientRequests => true; + public override Implementation? ClientInfo => server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index b57852f..d3848e0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -132,6 +132,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + var capabilities = json["result"]!["capabilities"]!.AsObject(); + Assert.False(capabilities.ContainsKey("logging")); + Assert.True(capabilities.ContainsKey("tools")); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. @@ -349,12 +352,19 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); } - [Fact] - public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602() + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson) { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; + var versionProperty = protocolVersionJson is null ? "" : @"""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + ","; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{" + versionProperty + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); @@ -370,8 +380,24 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } + [Theory] + [InlineData("{}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":{}}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":""2025-11-25""}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task ModernOnlyServer_HeaderlessRequest_StillRequiresModernMetadata(string metaJson, McpErrorCode expectedError) + { + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":" + metaJson + "}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)expectedError, json["error"]!["code"]!.GetValue()); + } + [Fact] - public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() + public async Task MissingProtocolVersionHeader_WithModernMetadata_IsServedFromBodyMetadata() { await StartAsync(); @@ -381,10 +407,61 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + // The 2025-03-26 header default only applies when the server has "no other way to identify the + // version". The body metadata is another way, so the transport does not reject the request on the + // strength of a reserved _meta value. stdio, which has no header at all, behaves identically. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); - Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + Assert.NotNull(json["result"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithUnsupportedMetadata_ReturnsUnsupportedProtocolVersion_Minus32022() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal("9999-99-99", json["error"]!["data"]!["requested"]!.GetValue()); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithMalformedMetadata_IsIgnored() + { + await StartAsync(); + + // No header and no established era means legacy handling, where the reserved namespace is opaque. + // A value we cannot read is not a version claim, so it must not turn a legacy request into an error. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":{}}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithLegacyMetadata_IsIgnored() + { + await StartAsync(); + + // A legacy reserved value on a headerless request stays opaque, which is the ChatGPT shape from #1783. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2025-06-18""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); } [Fact] @@ -405,11 +482,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn } [Fact] - public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() + public async Task InitializeHandshake_IgnoresFutureMetadata_OnDefaultServer() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -453,6 +532,29 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); } + [Theory] + [InlineData(@"""2025-11-25""")] + [InlineData(@"""2026-07-28""")] + [InlineData(@"""9999-99-99""")] + [InlineData("{}")] + public async Task Legacy2025Post_IgnoresFuturePerRequestMetadata(string protocolVersionJson) + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""legacy_meta_probe"",""arguments"":{}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + "," + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{""sampling"":{}}}}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("future-metadata-ignored", json["result"]!["content"]![0]!["text"]!.GetValue()); + } + [Fact] public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405() { @@ -523,6 +625,18 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn { private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1); + [McpServerTool(Name = "legacy_meta_probe")] + public static string LegacyMetaProbe(RequestContext context) + { + var requestContext = context.JsonRpcRequest.Context; + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, requestContext?.ProtocolVersion); + Assert.Null(requestContext?.ClientInfo); + Assert.Null(requestContext?.ClientCapabilities); + Assert.Null(context.Server.ClientInfo); + Assert.Null(context.Server.ClientCapabilities); + return "future-metadata-ignored"; + } + [McpServerTool(Name = "requires_sampling")] public static string RequiresSampling() => throw new MissingRequiredClientCapabilityException( diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 092f8f2..a95d989 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -9,6 +10,7 @@ using System.Diagnostics; using System.Net; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -154,6 +156,43 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem Assert.Equal("Server to client requests are not supported in stateless mode.", Assert.IsType(toolContent).Text); } + [Fact] + public async Task ClientCapabilities_AreAvailableFromInjectedServer() + { + await StartAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.RootsHandler = (_, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); + + var toolResponse = await client.CallToolAsync( + "getClientCapabilities", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "sampling|roots|elicitation", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + + [Fact] + public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode() + { + await StartAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); + + var toolResponse = await client.CallToolAsync( + "testInterceptedRequests", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "intercepted-sample|Seattle", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + [Fact] public async Task UnsolicitedNotification_Fails_WithInvalidOperationException() { @@ -575,8 +614,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode."; - // Even when the client has sampling support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Sampling); var asSamplingChatClientEx = Assert.Throws(() => server.AsSamplingChatClient()); Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message); @@ -596,8 +635,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedRootsErrorMessage = "Roots are not supported in stateless mode."; - // Even when the client has roots support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Roots); var requestRootsEx = Assert.Throws(() => server.RequestRootsAsync(new())); Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message); @@ -614,8 +653,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode."; - // Even when the client has elicitation support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Elicitation); var requestElicitationEx = await Assert.ThrowsAsync(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask()); Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message); @@ -627,6 +666,39 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem return ex.Message; } + [McpServerTool(Name = "getClientCapabilities")] + public static string GetClientCapabilities(McpServer server) => + string.Join( + '|', + server.ClientCapabilities?.Sampling is null ? "no-sampling" : "sampling", + server.ClientCapabilities?.Roots is null ? "no-roots" : "roots", + server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation"); + + [McpServerTool(Name = "testInterceptedRequests")] + public static async Task TestInterceptedRequests(McpServer server) + { + // Background task execution replaces the server-to-client channel with an interceptor that parks the + // request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not + // depend on session affinity, which is why these requests are allowed through in stateless mode. +#pragma warning disable MCPEXP002 + var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch + { + RequestMethods.SamplingCreateMessage => + """{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""", + RequestMethods.ElicitationCreate => + """{"action":"accept","content":{"city":"Seattle"}}""", + _ => throw new UnreachableException(), + }))); +#pragma warning restore MCPEXP002 + + var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?"); + var elicitResult = await intercepted.ElicitAsync( + "Which city?", + options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options }); + + return $"{samplingResponse.Text}|{elicitResult.Content?.City}"; + } + [McpServerTool(Name = "testScope")] public static string? TestScope(ScopedService scopedService) => scopedService.State; @@ -640,3 +712,12 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem public void Report(T value) => handler(value); } } + +public class CityForm +{ + public string? City { get; set; } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CityForm))] +internal partial class StatelessInterceptorJsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 3afbb52..94558a6 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -137,6 +137,53 @@ public class McpClientMetaTests : ClientServerTestBase Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializedSessionState() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + Assert.Null(context.JsonRpcRequest.Context?.ClientInfo); + Assert.Null(context.JsonRpcRequest.Context?.ClientCapabilities); + + Assert.Equal("initialized-client", context.Server.ClientInfo?.Name); + Assert.NotNull(context.Server.ClientCapabilities?.Elicitation); + Assert.Null(context.Server.ClientCapabilities?.Sampling); + + return "ok"; + }, + new() { Name = "legacy_meta_tool" })); + + var clientOptions = new McpClientOptions + { + ProtocolVersion = LatestStableVersion, + ClientInfo = new Implementation { Name = "initialized-client", Version = "1.0.0" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + new CallToolRequestParams + { + Name = "legacy_meta_tool", + Meta = new JsonObject + { + [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode( + new Implementation { Name = "request-client", Version = "2.0.0" }, + McpJsonUtilities.DefaultOptions), + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal("ok", Assert.IsType(Assert.Single(result.Content)).Text); + } + [Fact] public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() { @@ -250,9 +297,9 @@ public class McpClientMetaTests : ClientServerTestBase ClientCapabilities? handlerObservedCapabilities = null; Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( - (RequestContext context) => + (McpServer server) => { - handlerObservedCapabilities = context.Server.ClientCapabilities; + handlerObservedCapabilities = server.ClientCapabilities; return "ok"; }, new() { Name = "capability_probe_tool" })); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index e54f40d..0ab19a3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -289,11 +289,20 @@ public class McpServerTests : LoggedTest Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024-11-05", result.ProtocolVersion); Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); + Assert.True(Assert.IsType(response)["capabilities"]!.AsObject().ContainsKey("logging")); }); } - [Fact] - public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() + [Theory] + [InlineData("2025-11-25", "\"2025-03-26\"", null)] + [InlineData("2025-11-25", "\"2026-07-28\"", null)] + [InlineData("2025-11-25", "{}", null)] + [InlineData("2026-07-28", "\"2025-11-25\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "\"9999-99-99\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "{}", McpErrorCode.InvalidParams)] + [InlineData("9999-99-99", "{}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task TransportProtocolVersion_IsValidatedBeforeBodyMetadata( + string transportVersion, string metadataVersionJson, McpErrorCode? expectedError) { var ct = TestContext.Current.CancellationToken; await using var transport = new TestServerTransport(); @@ -303,71 +312,44 @@ public class McpServerTests : LoggedTest await using var server = McpServer.Create(transport, options, LoggerFactory); var runTask = server.RunAsync(ct); - var rejectedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var acceptedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); transport.OnMessageSent = message => { - if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1") + if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1") { - rejectedResponse.TrySetResult(error); - } - else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2") - { - acceptedResponse.TrySetResult(message); + response.TrySetResult(message); } }; await transport.SendClientMessageAsync(new JsonRpcRequest { Id = new RequestId(1), - Method = RequestMethods.ToolsList, + Method = RequestMethods.Ping, Params = new JsonObject { ["_meta"] = new JsonObject { - [MetaKeys.ClientInfo] = new JsonObject - { - ["name"] = "test-client", - ["version"] = "1.0.0", - }, + [MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson), }, }, Context = new JsonRpcMessageContext { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + ProtocolVersion = transportVersion, ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, }, }, ct); - var error = await rejectedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); - Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); - Assert.Null(server.NegotiatedProtocolVersion); - - var clientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }; - var clientCapabilities = new ClientCapabilities(); - await transport.SendClientMessageAsync(new JsonRpcRequest + var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + if (expectedError is { } errorCode) { - Id = new RequestId(2), - Method = RequestMethods.ToolsList, - Params = new JsonObject - { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, - [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.DefaultOptions), - [MetaKeys.ClientCapabilities] = new JsonObject(), - }, - }, - Context = new JsonRpcMessageContext - { - ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, - ClientInfo = clientInfo, - ClientCapabilities = clientCapabilities, - }, - }, ct); - - await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); - Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, server.NegotiatedProtocolVersion); + Assert.Equal((int)errorCode, Assert.IsType(message).Error.Code); + Assert.Null(server.NegotiatedProtocolVersion); + } + else + { + Assert.IsType(message); + Assert.Equal(transportVersion, server.NegotiatedProtocolVersion); + } await transport.DisposeAsync(); await runTask; diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index d8cadeb..b4bf440 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -25,6 +25,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl private readonly Pipe _serverToClient = new(); private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); private readonly ServiceProvider _services; + private readonly McpServer _server; private readonly Task _serverTask; private readonly StreamWriter _writer; private readonly StreamReader _reader; @@ -35,14 +36,15 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); serviceCollection.AddSingleton(XunitLoggerProvider); + serviceCollection.AddSingleton(MockLoggerProvider); serviceCollection .AddMcpServer() .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) .WithTools(); _services = serviceCollection.BuildServiceProvider(validateScopes: true); - var server = _services.GetRequiredService(); - _serverTask = server.RunAsync(_cts.Token); + _server = _services.GetRequiredService(); + _serverTask = _server.RunAsync(_cts.Token); _writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true }; _reader = new StreamReader(_serverToClient.Reader.AsStream()); @@ -69,16 +71,115 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Fact] - public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize() + public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory() { var ct = TestContext.Current.CancellationToken; - var error = Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); - Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); - Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + Assert.IsType( + await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); - // The rejected initialize-handshake _meta request must not have established session state. + // The advisory legacy value must not block a subsequent modern request from selecting its era. Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsIgnored(string protocolVersionJson) + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson), + }, + }, + }; + + // An unreadable value leaves the request on the legacy path without establishing a version. + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + + var warning = Assert.Single( + MockLoggerProvider.LogMessages, + m => m.LogLevel == LogLevel.Warning && m.Message.Contains(MetaKeys.ProtocolVersion, StringComparison.Ordinal)); + Assert.Contains(RequestMethods.ToolsList, warning.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task MalformedProtocolVersionMetadata_OnServerDiscover_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = new JsonObject(), + }, + }, + }; + + // server/discover exists only on per-request-metadata revisions, so it cannot fall back to legacy + // handling. This is what keeps a modern client from silently getting legacy treatment on a transport + // with no protocol version header. + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task ModernSession_RejectsMissingOrMalformedProtocolVersionMetadata(string? protocolVersionJson) + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); + + var meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + if (protocolVersionJson is not null) + { + meta[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson); + } + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = meta, + }, + }; + + // Once the era is established the metadata is required, so an unreadable value is an error rather + // than a reason to drop back to legacy handling. + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); } [Fact] @@ -110,6 +211,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); } [Fact] @@ -142,7 +244,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Fact] - public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() + public async Task Initialize_IgnoresFutureReservedMetadata() { var ct = TestContext.Current.CancellationToken; @@ -157,18 +259,101 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, Meta = new JsonObject { - [MetaKeys.ClientInfo] = new JsonObject - { - ["name"] = "per-request-meta-client", - ["version"] = "1.0.0", - }, + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", }, }, McpJsonUtilities.DefaultOptions), }; + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + } + + [Fact] + public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.March2025ProtocolVersion, ct)); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresModernProtocolVersionMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = new JsonObject(), + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", + }, + }, + }; + + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + } + + [Theory] + [InlineData(MetaKeys.ClientCapabilities, "\"not-an-object\"")] + [InlineData(MetaKeys.ClientCapabilities, "null")] + [InlineData(MetaKeys.ClientCapabilities, "[]")] + [InlineData(MetaKeys.ClientInfo, "\"not-an-object\"")] + [InlineData(MetaKeys.LogLevel, "\"not-a-level\"")] + public async Task ModernRequest_RejectsMalformedMetadata_WithoutEstablishingVersion(string key, string valueJson) + { + var ct = TestContext.Current.CancellationToken; + var meta = PerRequestMetadata(); + meta[key] = JsonNode.Parse(valueJson); + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = meta, + }, + }; + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); - Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(key, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); + + Assert.IsType( + await RoundTripInitializeAsync(id: 2, McpProtocolVersions.November2025ProtocolVersion, ct)); } [Fact] @@ -192,7 +377,6 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Theory] - [InlineData("initialize")] [InlineData("ping")] [InlineData("logging/setLevel")] [InlineData("resources/subscribe")] diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index c068d22..a41adfc 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -166,14 +166,14 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable } [Fact] - public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() + public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-path servers must accept 2026-07-28 per-request metadata and initialize-handshake traffic - // on the same connection. The exact mix below is what a permissive client running against an unknown - // server would emit while probing. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + // Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails. + // This is the sequence a permissive client emits against an unknown server: the failed probe never + // establishes a version, so initialize is still available. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"); var discover = await ReadAsync(); - Assert.NotNull(discover["result"]); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue()); await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); @@ -186,5 +186,29 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var call = await ReadAsync(); Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); } + + [Theory] + [InlineData("tools/list")] + [InlineData("server/discover")] + public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod) + { + // A modern request locks the session's protocol version. initialize declares nothing on stdio, so + // without this gate it would force the session back to a handshake version and make the modern + // _meta on in-flight requests opaque. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + var established = await ReadAsync(); + Assert.NotNull(established["result"]); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); + var init = await ReadAsync(); + Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue()); + + // The session is still modern: a request carrying full per-request metadata keeps working. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," + + July2026ProtocolMetaFragment() + "}}"); + var call = await ReadAsync(); + Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue()); + } } #endif diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 6fa9fa2..af4002e 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -123,23 +123,23 @@ public class TaskProtocolGatingTests : ClientServerTestBase } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_IgnoresPerRequestCapability() { await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy - // request. The server rejects reserved per-request metadata before it can affect behavior. - var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsTaskAsync( + // A forward-compatible client may carry SEP-2575 capabilities on a legacy request. The + // metadata is tolerated, but it cannot opt the legacy session into modern-only tasks. + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "forged"), Meta = CreateForgedTaskOptInMeta(), - }, ct)); + }, ct); - Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode); - Assert.Contains(ClientCapabilitiesMetaKey, ex.Message); + Assert.False(result.IsTask); + Assert.NotNull(result.Result); } [Fact] From c40ee044fd415c70da5176c749cb5ef02f2b59f6 Mon Sep 17 00:00:00 2001 From: Scott Holodak Date: Fri, 18 Sep 2026 20:07:30 -0400 Subject: [PATCH 8/8] fix(client): skip the SSE fallback when the server/discover probe is rejected with 400/404 (#1855) --- .../AutoDetectingClientSessionTransport.cs | 34 +++- .../HttpClientTransportAutoDetectTests.cs | 151 ++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index b3041ec..7563acd 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -99,8 +99,6 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport // behavior. Capture the underlying error (status + body) before falling back so that, // if SSE also fails, we can surface the real Streamable HTTP diagnostic to the caller // instead of dropping it on the floor (see https://github.com/modelcontextprotocol/csharp-sdk/issues/1526). - LogStreamableHttpFailed(_name, response.StatusCode); - // This reads the response body a second time for the application/json case, where // TryReadJsonRpcErrorAsync above already read it. HttpContent buffers after the first // read, so this returns the same buffered content and is safe (not a second stream @@ -108,6 +106,26 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport // TryReadJsonRpcErrorAsync returns early on the content type, so there is no double read. var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); + if (IsDiscoverProbeRejection(message, response.StatusCode)) + { + // The server/discover probe is protocol negotiation, not transport detection. A server + // predating SEP-2575 rejects the session-less POST with 400 (can't parse the request) or + // 404 (requires Mcp-Session-Id on every non-initialize POST) whether it speaks Streamable + // HTTP or SSE, so neither status is evidence about which transport to use. McpClientImpl + // .ConnectAsync treats exactly these two statuses as "initialize-handshake server" and + // immediately retries with initialize on this same transport — and that attempt still + // falls back to SSE, so an SSE-only server is reached one POST later rather than not at + // all. Attempting SSE here instead spends a GET whose result is discarded on every + // connect to a Streamable-HTTP-only server that predates SEP-2575, and logs a "falling + // back to SSE transport" line that misreports settled protocol negotiation as a failure. + LogSkippingSseFallbackForDiscoverProbe(_name, response.StatusCode); + + await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); + throw streamableHttpError; + } + + LogStreamableHttpFailed(_name, response.StatusCode); + await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false); } @@ -122,6 +140,15 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport } } + /// + /// Returns when the failed request was the SEP-2575 server/discover probe and the + /// status is one of the two already reads as "this server requires the initialize + /// handshake", meaning the SSE fallback cannot contribute anything the initialize retry won't. + /// + private static bool IsDiscoverProbeRejection(JsonRpcMessage message, HttpStatusCode statusCode) => + statusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound && + message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }; + private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken) { if (_options.KnownSessionId is not null) @@ -184,6 +211,9 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")] private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode); + [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} server/discover probe rejected with status code {StatusCode}; skipping the SSE fallback so the initialize handshake is attempted instead.")] + private partial void LogSkippingSseFallbackForDiscoverProbe(string endpointName, HttpStatusCode statusCode); + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")] private partial void LogUsingStreamableHttp(string endpointName); diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index 7100e72..c463833 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -505,4 +505,155 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SSE fallback failed")); } + + // A 400/404 on the SEP-2575 server/discover probe is protocol negotiation, not transport detection: + // a pre-SEP-2575 server rejects the session-less POST whether it speaks Streamable HTTP or SSE, and + // McpClientImpl.ConnectAsync reads exactly those two statuses as "initialize-handshake server" and + // retries with initialize. The SSE GET can therefore only waste a round trip here. + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.BadRequest)] + public async Task AutoDetectMode_SkipsSseFallback_WhenDiscoverProbeIsRejected(HttpStatusCode statusCode) + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var getCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + getCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent("Not Found"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, getCount); + Assert.Equal(statusCode, ex.Data["ModelContextProtocol.HttpStatusCode"]); + Assert.DoesNotContain( + MockLoggerProvider.LogMessages, + m => m.Message.Contains("falling back to SSE transport")); + } + + // Skipping the SSE attempt on the discover probe must not strand an SSE-only server: the initialize + // retry that McpClientImpl.ConnectAsync issues next goes through this same transport and still falls + // back to SSE, so such a server is reached one POST later rather than not at all. + [Fact] + public async Task AutoDetectMode_StillFallsBackToSse_WhenInitializeFollowsRejectedDiscoverProbe() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe then initialize test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var ssePipe = new Pipe(); + var sseEndpointPostCount = 0; + + await ssePipe.Writer.WriteAsync( + System.Text.Encoding.UTF8.GetBytes("event: endpoint\r\ndata: /sse-endpoint\r\n\r\n"), + TestContext.Current.CancellationToken); + await ssePipe.Writer.FlushAsync(TestContext.Current.CancellationToken); + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + var content = new StreamContent(ssePipe.Reader.AsStream()); + content.Headers.ContentType = new("text/event-stream"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + } + + if (request.RequestUri?.AbsolutePath == "/sse-endpoint") + { + sseEndpointPostCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Mcp-Session-Id required"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(2) }, + TestContext.Current.CancellationToken); + + Assert.Equal(1, sseEndpointPostCount); + + await ssePipe.Writer.CompleteAsync(); + } + + // The skip is scoped to the two statuses ConnectAsync acts on. Any other failure on the discover probe + // keeps the original fallback, because it is not evidence that an initialize retry is coming. + [Fact] + public async Task AutoDetectMode_FallsBackToSse_WhenDiscoverProbeFailsWithUnrelatedStatus() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe 415 test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var getCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + getCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.UnsupportedMediaType) + { + Content = new StringContent("Content-Type must be 'application/json'"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(1, getCount); + } }