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); - } }