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:"
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 @@
-
+
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
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/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
index c9e821a..7563acd 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
{
@@ -96,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
@@ -105,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);
}
@@ -119,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)
@@ -181,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/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/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index 61a1872..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
@@ -962,9 +895,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/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/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/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.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()
{
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..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.
@@ -207,6 +210,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()
{
@@ -328,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);
@@ -349,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();
@@ -360,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]
@@ -384,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);
@@ -432,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()
{
@@ -500,10 +623,33 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
[McpServerToolType]
private sealed class CapabilityTools
{
+ 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(
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");
+ }
}
}
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/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) };
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]
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);
+ }
}