diff --git a/.github/release-process.md b/.github/release-process.md
index d7418b0..79a91d1 100644
--- a/.github/release-process.md
+++ b/.github/release-process.md
@@ -49,7 +49,4 @@ 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. 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.
+ A content-only docs refresh can be run later via manual dispatch with the `docs_ref` input.
diff --git a/.github/skills/verify-release/SKILL.md b/.github/skills/verify-release/SKILL.md
index ad54776..1f8a706 100644
--- a/.github/skills/verify-release/SKILL.md
+++ b/.github/skills/verify-release/SKILL.md
@@ -103,14 +103,11 @@ 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.** 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.
+- **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.
- **Orchestration comes from `main`.** The scripts and picker assets are always checked out from
- `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
+ `main`, while each version's content comes from its release tag. 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
@@ -167,21 +164,17 @@ Both remediations require explicit user confirmation.
gh run rerun {run-id} --failed
```
-**Rebuild the released docs** — use the default `release-tags` source to retry a failed Pages
-deployment after a release, without minting a product release:
+**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:
```
-gh workflow run docs.yml --field docs_source=release-tags
+gh workflow run docs.yml --field docs_ref={branch-tag-or-commit}
```
-**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.
+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.
## Edge Cases
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 458e3fc..1e187ef 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 build every
-# major-version path from release tags or current source branches.
+# version, and deploys the combined site. A manual dispatch can also rebuild
+# the matching major-version path from a specified branch, tag, or commit.
# Triggers on release publish and manual dispatch.
on:
@@ -15,14 +15,10 @@ on:
types: [published]
workflow_dispatch:
inputs:
- docs_source:
- description: Source for every published major-version path
- required: true
- default: release-tags
- type: choice
- options:
- - release-tags
- - latest-branches
+ docs_ref:
+ description: Branch, tag, or commit whose docs should refresh its matching major version
+ required: false
+ type: string
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
@@ -90,7 +86,7 @@ jobs:
- name: Build all versions
shell: bash
env:
- USE_SOURCE_BRANCHES: ${{ inputs.docs_source == 'latest-branches' }}
+ CONTENT_REF: ${{ inputs.docs_ref }}
run: |
set -euo pipefail
ROOT="$PWD"
@@ -100,43 +96,28 @@ 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 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"
+ # 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"
fi
while IFS=$'\t' read -r slug ref; do
- 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
+ if [[ "$slug" == "$CONTENT_SLUG" ]]; then
+ echo "::group::Build $slug (from ref $CONTENT_REF)"
+ wt="$CONTENT_WORKTREE"
else
echo "::group::Build $slug (from tag $ref)"
wt="$ROOT/../work-$slug"
@@ -149,10 +130,16 @@ jobs:
cp -a "$wt/artifacts/_site/." "$COMBINED/$slug/"
node "$ROOT/scripts/inject-version-picker.mjs" "$COMBINED/$slug" "$slug" --base /
- git worktree remove --force "$wt"
+ if [[ "$wt" != "$CONTENT_WORKTREE" ]]; then
+ git worktree remove --force "$wt"
+ fi
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 565d016..69ed858 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -58,7 +58,7 @@
-
+
diff --git a/docs/roadmap.md b/docs/roadmap.md
index e012cc3..af0f075 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 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).
+The next MCP specification 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 f26e9ed..50c20a7 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -846,17 +846,23 @@ internal sealed class StreamableHttpHandler(
return false;
}
- // 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))
+ bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta);
+
+ if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
+ !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta))
{
errorDetail = null;
return true;
}
- // Header validity was checked first, so only supported modern headers reach this point.
- if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta))
+ 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)
{
// 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 7563acd..c9e821a 100644
--- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
@@ -77,19 +77,16 @@ 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.
- // 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.
+ // 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.
LogUsingStreamableHttp(_name);
ActiveTransport = streamableHttpTransport;
-
- 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);
+ throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
}
else
{
@@ -99,6 +96,8 @@ 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
@@ -106,26 +105,6 @@ 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);
}
@@ -140,15 +119,6 @@ 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)
@@ -211,9 +181,6 @@ 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 d1f2a9d..a62ad0e 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@@ -367,6 +367,14 @@ 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 5b29449..db67b2e 100644
--- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
@@ -80,7 +80,8 @@ 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 &&
- ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError))
+ (response.StatusCode == HttpStatusCode.BadRequest ||
+ IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code)))
{
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
}
@@ -88,11 +89,10 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
}
- internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) =>
- statusCode == HttpStatusCode.BadRequest ||
- (McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion
- or McpErrorCode.MissingRequiredClientCapability
- or McpErrorCode.HeaderMismatch;
+ private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) =>
+ 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 d2b0745..61a1872 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -401,6 +401,14 @@ 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);
@@ -536,6 +544,65 @@ 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
@@ -895,10 +962,9 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
tags.Add("network.protocol.name", "http");
}
- string? protocolVersion = (message as JsonRpcRequest)?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion;
- if (protocolVersion is not null)
+ if (NegotiatedProtocolVersion is not null)
{
- tags.Add("mcp.protocol.version", protocolVersion);
+ tags.Add("mcp.protocol.version", NegotiatedProtocolVersion);
}
if (activity is { IsAllDataRequested: true })
diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
index 0b56caa..def9b89 100644
--- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
+++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
@@ -88,12 +88,13 @@ public sealed class JsonRpcMessageContext
public string? RoutingName { get; set; }
///
- /// Gets or sets the authoritative protocol version for this JSON-RPC message.
+ /// Gets or sets the protocol version from the transport-level header (e.g. Mcp-Protocol-Version)
+ /// that accompanied this JSON-RPC message.
///
///
- /// 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.
+ /// 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.
///
public string? ProtocolVersion { get; set; }
@@ -103,8 +104,7 @@ 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. Future reserved
- /// metadata remains opaque under legacy revisions, which continue to use their initialized identity.
+ /// the server uses this in lieu of the value previously captured during the initialize handshake.
///
public Implementation? ClientInfo { get; set; }
@@ -114,9 +114,7 @@ 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 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.
+ /// capabilities from previous requests; the authoritative value is the one declared on each request.
///
public ClientCapabilities? ClientCapabilities { get; set; }
@@ -126,8 +124,8 @@ public sealed class JsonRpcMessageContext
///
///
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Replaces the legacy
- /// 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.
+ /// RPC. When absent, the server MUST NOT emit log notifications
+ /// for the request.
///
public LoggingLevel? LogLevel { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs
index 06a8777..7aab348 100644
--- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs
+++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs
@@ -23,6 +23,14 @@ 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
@@ -34,13 +42,11 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
}
// Legacy protocol behavior uses session-scoped capabilities established during initialize (or
- // pre-populated migration data). Future reserved metadata remains opaque under legacy semantics.
+ // pre-populated migration data), so ignore per-request values and return the server session state.
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 2e51f51..a9a5ddd 100644
--- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
@@ -25,8 +25,6 @@ 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.
///
@@ -557,26 +555,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.");
}
}
@@ -617,12 +615,12 @@ public abstract partial class McpServer : McpSession
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
{
- if (!SupportsServerToClientRequests)
+ if (ClientCapabilities is null)
{
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 29f524a..4797ce1 100644
--- a/src/ModelContextProtocol.Core/Server/McpServer.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServer.cs
@@ -32,11 +32,12 @@ 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.
///
///
- /// 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.
+ /// Server implementations can check these capabilities to determine which features
+ /// are available when interacting with the client.
///
///
public abstract ClientCapabilities? ClientCapabilities { get; }
diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index 76a65ed..2ce8387 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -33,6 +33,14 @@ 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;
@@ -114,7 +122,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);
@@ -152,46 +160,124 @@ internal sealed partial class McpServerImpl : McpServer
///
/// Wraps so that, for every JSON-RPC request, a built-in filter first
- /// classifies and projects modern per-request _meta, synchronizes server-side state
- /// (, ), and validates protocol
- /// boundaries before delegating to user-supplied incoming filters.
+ /// 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.
///
///
/// 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. Under initialize-handshake
- /// revisions, reserved per-request metadata is opaque and is not parsed or projected. Modern envelopes remain
- /// strictly parsed.
+ /// 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).
///
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
{
JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) =>
{
- if (message is JsonRpcRequest request)
+ if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest)
{
- if (request.Method == RequestMethods.Initialize)
- {
- ValidateInitializeRequestBoundary(request);
- }
- else
- {
- ReadRequestMetadata(request);
- ValidateRequestMethodBoundary(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);
- 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)))
+ if (context?.ProtocolVersion is { } protocolVersion)
+ {
+ bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null;
+ if (protocolVersionAlreadyEstablished)
{
- // Modern handlers resolve client info request-scoped through DestinationBoundMcpServer.
- // This shared write is only for endpoint logging.
- _clientInfo = clientInfo;
- UpdateEndpointNameWithClientInfo();
- _sessionHandler.EndpointName = _endpointName;
+ 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);
+ }
+ }
+ else if (_negotiatedProtocolVersion is null)
+ {
+ if (request.Method == RequestMethods.ServerDiscover)
+ {
+ throw new McpProtocolException(
+ $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.",
+ McpErrorCode.InvalidParams);
+ }
+
+ 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)
@@ -205,158 +291,56 @@ internal sealed partial class McpServerImpl : McpServer
return next => metaReadingFilter(inner(next));
}
- private void ReadRequestMetadata(JsonRpcRequest request)
+ private static void ValidateRequiredPerRequestMetadata(
+ string protocolVersion,
+ bool hasProtocolVersionMeta,
+ bool hasClientCapabilitiesMeta)
{
- 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))
+ if (!hasProtocolVersionMeta)
{
- if (transportProtocolVersion is not null)
- {
- if (!_supportedProtocolVersions.Contains(transportProtocolVersion))
- {
- throw new UnsupportedProtocolVersionException(
- requested: transportProtocolVersion,
- supported: _supportedProtocolVersions);
- }
-
- SetNegotiatedProtocolVersion(transportProtocolVersion);
- }
-
- return;
+ ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion);
}
- 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;
+ // clientInfo is optional: requests whose _meta omits it are served, not rejected.
- if (transportProtocolVersion is not null &&
- metadataProtocolVersion is not null &&
- !string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal))
+ if (!hasClientCapabilitiesMeta)
{
- 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);
+ ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities);
}
}
- 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(
+ private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) =>
+ throw new McpProtocolException(
$"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.
@@ -388,17 +372,12 @@ internal sealed partial class McpServerImpl : McpServer
private void ValidateInitializeRequestBoundary(JsonRpcRequest request)
{
- // 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)
+ // 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))
{
throw new McpProtocolException(
- $"Method '{RequestMethods.Initialize}' is not available on protocol version '{modernProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.",
+ $"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.",
McpErrorCode.MethodNotFound);
}
@@ -410,6 +389,23 @@ 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)
@@ -515,17 +511,13 @@ internal sealed partial class McpServerImpl : McpServer
public ServerCapabilities ServerCapabilities { get; }
///
- /// Returns the to advertise in a specific response, suppressing
- /// capabilities that are not available on that response's protocol path.
+ /// Returns the to advertise in a specific response, suppressing the
+ /// listChanged flags the server has no way to honor.
///
///
/// 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
@@ -533,15 +525,11 @@ 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. 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.
+ /// deliver. Everything else (for example resources.subscribe) is preserved.
///
- private ServerCapabilities GetAdvertisedCapabilities(
- bool listenStreamCanDeliverListChanged,
- bool includeDeprecatedLogging)
+ private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged)
{
- bool includeListChanged = HasStatefulTransport || listenStreamCanDeliverListChanged;
- if (includeListChanged && includeDeprecatedLogging)
+ if (HasStatefulTransport() || listenStreamCanDeliverListChanged)
{
return ServerCapabilities;
}
@@ -551,32 +539,20 @@ internal sealed partial class McpServerImpl : McpServer
return new ServerCapabilities
{
Experimental = ServerCapabilities.Experimental,
- Logging = includeDeprecatedLogging ? ServerCapabilities.Logging : null,
+ Logging = ServerCapabilities.Logging,
Completions = ServerCapabilities.Completions,
Extensions = ServerCapabilities.Extensions,
- Prompts = ServerCapabilities.Prompts is null
- ? null
- : includeListChanged
- ? ServerCapabilities.Prompts
- : new PromptsCapability { ListChanged = null },
+ Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null },
Resources = ServerCapabilities.Resources is { } resources
- ? includeListChanged
- ? resources
- : new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
+ ? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
: null,
- Tools = ServerCapabilities.Tools is null
- ? null
- : includeListChanged
- ? ServerCapabilities.Tools
- : new ToolsCapability { ListChanged = null },
+ Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null },
};
}
///
public override ClientCapabilities? ClientCapabilities => _clientCapabilities;
- internal override bool SupportsServerToClientRequests => HasStatefulTransport;
-
///
public override Implementation? ClientInfo => _clientInfo;
@@ -712,8 +688,11 @@ internal sealed partial class McpServerImpl : McpServer
string negotiatedProtocolVersion = protocolVersion ?? McpProtocolVersions.November2025ProtocolVersion;
- // initialize may supersede a legacy transport version. ValidateInitializeRequestBoundary
- // prevents it from downgrading an established modern session.
+ // 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.
_negotiatedProtocolVersion = negotiatedProtocolVersion;
_sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion;
@@ -726,9 +705,7 @@ 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,
- includeDeprecatedLogging: true),
+ Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false),
// 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
@@ -761,8 +738,7 @@ 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,
- includeDeprecatedLogging: false),
+ listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null),
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
@@ -865,7 +841,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,
@@ -2072,7 +2048,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".
///
- private bool ClientSupportsMrtr => IsJuly2026OrLaterProtocol();
+ internal bool ClientSupportsMrtr() => IsJuly2026OrLaterProtocol();
///
/// Returns when the session is stateful - the same server instance handles
@@ -2081,9 +2057,8 @@ internal sealed partial class McpServerImpl : McpServer
/// elicitation/create / sampling/createMessage / roots/list to the client and
/// retry the handler with the responses.
///
- private bool HasStatefulTransport =>
+ internal 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
@@ -2098,7 +2073,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
@@ -2142,7 +2117,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);
}
@@ -2150,7 +2125,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. " +
@@ -2389,7 +2364,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);
}
@@ -2552,9 +2527,6 @@ 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 e32a160..93b7e5e 100644
--- a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs
+++ b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs
@@ -17,11 +17,6 @@ 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 5b105c3..95411f7 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
@@ -76,6 +76,9 @@ 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);
@@ -84,33 +87,88 @@ internal sealed partial class StreamableHttpPostTransport(
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
}
- else if (onResponseStarting is not null &&
- McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion))
+ else if (onResponseStarting is null)
{
- // 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.
+ // 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);
}
else
{
- // 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);
+ deferHeaderFlush = true;
}
// Ensure that we've sent the priming event before processing the incoming request.
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
}
- // 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);
+ 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();
+ }
+ }
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.
@@ -285,4 +343,6 @@ 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 7f9a195..f143eaa 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 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).
+ /// 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).
/// The status line can only be influenced by the FIRST write: when a handler streams a
- /// notification (e.g. progress) before failing, the status is already committed and a later
- /// JSON-RPC error rides the committed status.
+ /// 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.
///
/// 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 9126331..599bdd9 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, bool acceptGet = false)
+ private async Task StartServerAsync(RequestDelegate handler)
{
Builder.Services.Configure(options =>
{
@@ -65,14 +65,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
});
_app = Builder.Build();
- if (acceptGet)
- {
- _app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler);
- }
- else
- {
- _app.MapPost("/mcp", handler);
- }
+ _app.MapPost("/mcp", handler);
await _app.StartAsync(TestContext.Current.CancellationToken);
}
@@ -309,63 +302,6 @@ 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 fbf1a5f..ac19953 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs
@@ -2,8 +2,6 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
-using OpenTelemetry.Trace;
-using System.Diagnostics;
namespace ModelContextProtocol.AspNetCore.Tests;
@@ -12,57 +10,6 @@ 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 0a0a6bb..889a7da 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs
@@ -6,9 +6,7 @@ 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;
@@ -48,60 +46,6 @@ 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 43a3c12..ef68321 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
@@ -239,9 +239,10 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
[Fact]
public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore()
{
- // 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.
+ // 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.
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools();
@@ -273,11 +274,7 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
TransportMode = transportMode,
}, shortTimeoutClient, LoggerFactory);
- await using var mcpClient = await McpClient.CreateAsync(
- transport,
- new McpClientOptions { ProtocolVersion = "2025-11-25" },
- LoggerFactory,
- TestContext.Current.CancellationToken);
+ await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: 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 d3848e0..0fce6dc 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs
@@ -132,9 +132,6 @@ 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.
@@ -210,27 +207,6 @@ 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()
{
@@ -352,19 +328,12 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported);
}
- [Theory]
- [InlineData(null)]
- [InlineData("null")]
- [InlineData("{}")]
- [InlineData("[]")]
- [InlineData("42")]
- public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson)
+ [Fact]
+ public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602()
{
await StartAsync();
- 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"":{}}}}";
+ var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""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);
@@ -380,24 +349,8 @@ 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 MissingProtocolVersionHeader_WithModernMetadata_IsServedFromBodyMetadata()
+ public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020()
{
await StartAsync();
@@ -407,61 +360,10 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
request.Headers.Add("Mcp-Method", "server/discover");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
- // 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);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
- 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"]);
+ Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue());
+ Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal);
}
[Fact]
@@ -482,13 +384,11 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
}
[Fact]
- public async Task InitializeHandshake_IgnoresFutureMetadata_OnDefaultServer()
+ public async Task InitializeHandshake_StillSucceeds_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""}," +
- @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}";
+ var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}";
using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
@@ -532,29 +432,6 @@ 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()
{
@@ -623,33 +500,10 @@ 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 a95d989..092f8f2 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
@@ -10,7 +9,6 @@ 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;
@@ -156,43 +154,6 @@ 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()
{
@@ -614,8 +575,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
{
const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode.";
- // The declaration is visible to application code, but it cannot make a session-dependent request safe.
- Assert.NotNull(server.ClientCapabilities?.Sampling);
+ // Even when the client has sampling support, it should not be advertised in stateless mode.
+ Assert.Null(server.ClientCapabilities);
var asSamplingChatClientEx = Assert.Throws(() => server.AsSamplingChatClient());
Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message);
@@ -635,8 +596,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
{
const string expectedRootsErrorMessage = "Roots are not supported in stateless mode.";
- // The declaration is visible to application code, but it cannot make a session-dependent request safe.
- Assert.NotNull(server.ClientCapabilities?.Roots);
+ // Even when the client has roots support, it should not be advertised in stateless mode.
+ Assert.Null(server.ClientCapabilities);
var requestRootsEx = Assert.Throws(() => server.RequestRootsAsync(new()));
Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message);
@@ -653,8 +614,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
{
const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode.";
- // The declaration is visible to application code, but it cannot make a session-dependent request safe.
- Assert.NotNull(server.ClientCapabilities?.Elicitation);
+ // Even when the client has elicitation support, it should not be advertised in stateless mode.
+ Assert.Null(server.ClientCapabilities);
var requestElicitationEx = await Assert.ThrowsAsync(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask());
Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message);
@@ -666,39 +627,6 @@ 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;
@@ -712,12 +640,3 @@ 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 557dc56..fed5df6 100644
--- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs
@@ -239,28 +239,6 @@ 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)]
@@ -339,47 +317,6 @@ 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 94558a6..3afbb52 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs
@@ -137,53 +137,6 @@ 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()
{
@@ -297,9 +250,9 @@ public class McpClientMetaTests : ClientServerTestBase
ClientCapabilities? handlerObservedCapabilities = null;
Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create(
- (McpServer server) =>
+ (RequestContext context) =>
{
- handlerObservedCapabilities = server.ClientCapabilities;
+ handlerObservedCapabilities = context.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 0ab19a3..e54f40d 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
@@ -289,20 +289,11 @@ 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"));
});
}
- [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)
+ [Fact]
+ public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion()
{
var ct = TestContext.Current.CancellationToken;
await using var transport = new TestServerTransport();
@@ -312,44 +303,71 @@ public class McpServerTests : LoggedTest
await using var server = McpServer.Create(transport, options, LoggerFactory);
var runTask = server.RunAsync(ct);
- var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var rejectedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var acceptedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
transport.OnMessageSent = message =>
{
- if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1")
+ if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1")
{
- response.TrySetResult(message);
+ rejectedResponse.TrySetResult(error);
+ }
+ else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2")
+ {
+ acceptedResponse.TrySetResult(message);
}
};
await transport.SendClientMessageAsync(new JsonRpcRequest
{
Id = new RequestId(1),
- Method = RequestMethods.Ping,
+ Method = RequestMethods.ToolsList,
Params = new JsonObject
{
["_meta"] = new JsonObject
{
- [MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson),
+ [MetaKeys.ClientInfo] = new JsonObject
+ {
+ ["name"] = "test-client",
+ ["version"] = "1.0.0",
+ },
},
},
Context = new JsonRpcMessageContext
{
- ProtocolVersion = transportVersion,
+ ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
},
}, ct);
- var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct);
- if (expectedError is { } errorCode)
+ 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
{
- Assert.Equal((int)errorCode, Assert.IsType(message).Error.Code);
- Assert.Null(server.NegotiatedProtocolVersion);
- }
- else
- {
- Assert.IsType(message);
- Assert.Equal(transportVersion, server.NegotiatedProtocolVersion);
- }
+ 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);
await transport.DisposeAsync();
await runTask;
diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs
index b4bf440..d8cadeb 100644
--- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs
@@ -25,7 +25,6 @@ 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;
@@ -36,15 +35,14 @@ 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);
- _server = _services.GetRequiredService();
- _serverTask = _server.RunAsync(_cts.Token);
+ var server = _services.GetRequiredService();
+ _serverTask = server.RunAsync(_cts.Token);
_writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true };
_reader = new StreamReader(_serverToClient.Reader.AsStream());
@@ -71,115 +69,16 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
}
[Fact]
- public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory()
+ public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize()
{
var ct = TestContext.Current.CancellationToken;
- Assert.IsType(
- await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct));
- Assert.Null(_server.NegotiatedProtocolVersion);
- Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning);
+ 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);
- // The advisory legacy value must not block a subsequent modern request from selecting its era.
+ // The rejected initialize-handshake _meta request must not have established session state.
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]
@@ -211,7 +110,6 @@ 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]
@@ -244,7 +142,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
}
[Fact]
- public async Task Initialize_IgnoresFutureReservedMetadata()
+ public async Task Initialize_WithReservedPerRequestMetadata_IsRejected()
{
var ct = TestContext.Current.CancellationToken;
@@ -259,101 +157,18 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
Meta = new JsonObject
{
- [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion,
- [MetaKeys.ClientInfo] = "not-an-object",
- [MetaKeys.ClientCapabilities] = "not-an-object",
- [MetaKeys.LogLevel] = "not-a-level",
+ [MetaKeys.ClientInfo] = new JsonObject
+ {
+ ["name"] = "per-request-meta-client",
+ ["version"] = "1.0.0",
+ },
},
}, 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.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));
+ Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code);
+ Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal);
}
[Fact]
@@ -377,6 +192,7 @@ 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 a41adfc..c068d22 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_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed()
+ public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed()
{
- // 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") + "}}");
+ // 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() + "}}");
var discover = await ReadAsync();
- Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue());
+ Assert.NotNull(discover["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();
@@ -186,29 +186,5 @@ 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 af4002e..6fa9fa2 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_IgnoresPerRequestCapability()
+ public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
- // 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(
+ // 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(
new CallToolRequestParams
{
Name = "test-tool",
Arguments = CreateArguments("input", "forged"),
Meta = CreateForgedTaskOptInMeta(),
- }, ct);
+ }, ct));
- Assert.False(result.IsTask);
- Assert.NotNull(result.Result);
+ Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode);
+ Assert.Contains(ClientCapabilitiesMetaKey, ex.Message);
}
[Fact]
diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs
index c463833..7100e72 100644
--- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs
@@ -505,155 +505,4 @@ 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);
- }
}