Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40ee044fd |
||
|
|
324ccd83c3 |
||
|
|
6001d5ac7a |
||
|
|
cb3e6e87fe |
||
|
|
df11d7da3b |
||
|
|
609499b298 |
||
|
|
20cdd493a4 |
||
|
|
8754e19f80 |
31 changed files with 1351 additions and 551 deletions
5
.github/release-process.md
vendored
5
.github/release-process.md
vendored
|
|
@ -49,4 +49,7 @@ monitor both and confirm their published outputs:
|
|||
published release tags and deploys it to
|
||||
[csharp.sdk.modelcontextprotocol.io](https://csharp.sdk.modelcontextprotocol.io). Verify the new
|
||||
version appears in the version picker and that its major-version path serves the updated content.
|
||||
A content-only docs refresh can be run later via manual dispatch with the `docs_ref` input.
|
||||
A content-only docs refresh can be run later via manual dispatch. Its `docs_source` input defaults
|
||||
to `release-tags` to mirror a release deployment; select `latest-branches` to refresh every
|
||||
published major from its current `release/{MAJOR}.x` branch, using `main` for its
|
||||
matching major.
|
||||
|
|
|
|||
29
.github/skills/verify-release/SKILL.md
vendored
29
.github/skills/verify-release/SKILL.md
vendored
|
|
@ -103,11 +103,14 @@ Report the run's conclusion, accounting for these docs-specific behaviors:
|
|||
- **Version discovery reads published releases.** For each major version >= 1, the workflow takes
|
||||
the most recently published non-draft release tagged `v{MAJOR}.*`. A draft release contributes
|
||||
nothing.
|
||||
- **Every major is rebuilt.** Each major's docs are built from that major's latest release tag into
|
||||
its own path (`/v1/`, `/v2/`). A new MAJOR adds a new path; the site root redirects to the newest
|
||||
release, prereleases included.
|
||||
- **Every major is rebuilt.** A release-triggered run builds each major from its latest release tag
|
||||
into its own path (`/v1/`, `/v2/`). A manual dispatch defaults to the same release-tag content,
|
||||
or can build every major from `release/{MAJOR}.x`, using `main` for its matching major,
|
||||
by selecting `docs_source=latest-branches`. A new MAJOR adds a new path; the site root redirects
|
||||
to the newest release, prereleases included.
|
||||
- **Orchestration comes from `main`.** The scripts and picker assets are always checked out from
|
||||
`main`, while each version's content comes from its release tag. A docs fix that lives only in a
|
||||
`main`. Release-triggered and default manual runs use release-tag content; a manual
|
||||
`docs_source=latest-branches` run uses source-branch content. A docs fix that lives only in a
|
||||
release branch will not affect orchestration.
|
||||
|
||||
If it failed, summarize the failing step. Common causes are a docs build failure in one version's
|
||||
|
|
@ -164,17 +167,21 @@ Both remediations require explicit user confirmation.
|
|||
gh run rerun {run-id} --failed
|
||||
```
|
||||
|
||||
**Refresh the docs without a new release** — when documentation content needs correcting after the
|
||||
release, the docs workflow accepts a manual dispatch that rebuilds one major version's content from
|
||||
an arbitrary ref, without minting a product release:
|
||||
**Rebuild the released docs** — use the default `release-tags` source to retry a failed Pages
|
||||
deployment after a release, without minting a product release:
|
||||
|
||||
```
|
||||
gh workflow run docs.yml --field docs_ref={branch-tag-or-commit}
|
||||
gh workflow run docs.yml --field docs_source=release-tags
|
||||
```
|
||||
|
||||
The ref's major version, read from `src/Directory.Build.props`, must have a published release; the
|
||||
workflow fails fast if it does not. This replaces only the matching major's HTML — orchestration
|
||||
and all other versions are unaffected.
|
||||
**Refresh docs without a new release** — select `latest-branches` to rebuild every published major
|
||||
from its current source branch (`release/{MAJOR}.x`, using `main` for its matching major):
|
||||
|
||||
```
|
||||
gh workflow run docs.yml --field docs_source=latest-branches
|
||||
```
|
||||
|
||||
The workflow fails instead of silently using a release tag when any major has no matching branch.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
|
|
|
|||
79
.github/workflows/docs.yml
vendored
79
.github/workflows/docs.yml
vendored
|
|
@ -6,8 +6,8 @@ name: Publish Docs
|
|||
# latest release, by date, for each major >= 1), builds each release tag into
|
||||
# its own sub-path (e.g. /v1/ and /v2/) with make generate-docs, injects a
|
||||
# version-picker widget into each page, adds a root redirect to the default
|
||||
# version, and deploys the combined site. A manual dispatch can also rebuild
|
||||
# the matching major-version path from a specified branch, tag, or commit.
|
||||
# version, and deploys the combined site. A manual dispatch can build every
|
||||
# major-version path from release tags or current source branches.
|
||||
# Triggers on release publish and manual dispatch.
|
||||
|
||||
on:
|
||||
|
|
@ -15,10 +15,14 @@ on:
|
|||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
docs_ref:
|
||||
description: Branch, tag, or commit whose docs should refresh its matching major version
|
||||
required: false
|
||||
type: string
|
||||
docs_source:
|
||||
description: Source for every published major-version path
|
||||
required: true
|
||||
default: release-tags
|
||||
type: choice
|
||||
options:
|
||||
- release-tags
|
||||
- latest-branches
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
|
|
@ -86,7 +90,7 @@ jobs:
|
|||
- name: Build all versions
|
||||
shell: bash
|
||||
env:
|
||||
CONTENT_REF: ${{ inputs.docs_ref }}
|
||||
USE_SOURCE_BRANCHES: ${{ inputs.docs_source == 'latest-branches' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ROOT="$PWD"
|
||||
|
|
@ -96,28 +100,43 @@ jobs:
|
|||
|
||||
# Orchestration (discovered docs-versions.json, scripts, picker assets)
|
||||
# always comes from THIS branch. Normally each version's HTML is produced
|
||||
# by its release tag. A manual docs_ref replaces the matching major's
|
||||
# HTML with content built from that ref, allowing content-only refreshes
|
||||
# without minting a product release.
|
||||
git fetch --tags --force origin
|
||||
CONTENT_REF="${CONTENT_REF:-}"
|
||||
CONTENT_WORKTREE=""
|
||||
CONTENT_SLUG=""
|
||||
if [[ -n "$CONTENT_REF" ]]; then
|
||||
CONTENT_WORKTREE="$ROOT/../work-content"
|
||||
git worktree add --force --detach "$CONTENT_WORKTREE" "$CONTENT_REF"
|
||||
CONTENT_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$CONTENT_WORKTREE/src/Directory.Build.props")"
|
||||
if ! node "$ROOT/scripts/list-versions.mjs" | cut -f1 | grep -Fxq "$CONTENT_SLUG"; then
|
||||
echo "::error::The docs ref '$CONTENT_REF' has major '$CONTENT_SLUG', which has no published release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Refreshing $CONTENT_SLUG docs from $CONTENT_REF"
|
||||
# by its release tag. A manual dispatch can instead build every major from
|
||||
# its current source branch.
|
||||
git fetch --tags --force --prune origin "+refs/heads/*:refs/remotes/origin/*"
|
||||
USE_SOURCE_BRANCHES="${USE_SOURCE_BRANCHES:-false}"
|
||||
|
||||
MAIN_SLUG=""
|
||||
if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then
|
||||
MAIN_PROPS="$(mktemp)"
|
||||
git show refs/remotes/origin/main:src/Directory.Build.props > "$MAIN_PROPS"
|
||||
MAIN_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$MAIN_PROPS")"
|
||||
rm -f "$MAIN_PROPS"
|
||||
echo "Refreshing all published docs from source branches (main serves $MAIN_SLUG)"
|
||||
else
|
||||
echo "Refreshing all published docs from release tags"
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r slug ref; do
|
||||
if [[ "$slug" == "$CONTENT_SLUG" ]]; then
|
||||
echo "::group::Build $slug (from ref $CONTENT_REF)"
|
||||
wt="$CONTENT_WORKTREE"
|
||||
if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then
|
||||
source_branch="release/${slug#v}.x"
|
||||
if ! git show-ref --verify --quiet "refs/remotes/origin/$source_branch"; then
|
||||
if [[ "$slug" == "$MAIN_SLUG" ]]; then
|
||||
source_branch="main"
|
||||
else
|
||||
echo "::error::No source branch found for $slug. Expected release/${slug#v}.x, or main with VersionPrefix for $slug."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "::group::Build $slug (from branch $source_branch)"
|
||||
wt="$ROOT/../work-$slug"
|
||||
git worktree add --force --detach "$wt" "refs/remotes/origin/$source_branch"
|
||||
source_slug="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$wt/src/Directory.Build.props")"
|
||||
if [[ "$source_slug" != "$slug" ]]; then
|
||||
git worktree remove --force "$wt"
|
||||
echo "::error::Source branch '$source_branch' has major '$source_slug', not '$slug'."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "::group::Build $slug (from tag $ref)"
|
||||
wt="$ROOT/../work-$slug"
|
||||
|
|
@ -130,16 +149,10 @@ jobs:
|
|||
cp -a "$wt/artifacts/_site/." "$COMBINED/$slug/"
|
||||
node "$ROOT/scripts/inject-version-picker.mjs" "$COMBINED/$slug" "$slug" --base /
|
||||
|
||||
if [[ "$wt" != "$CONTENT_WORKTREE" ]]; then
|
||||
git worktree remove --force "$wt"
|
||||
fi
|
||||
git worktree remove --force "$wt"
|
||||
echo "::endgroup::"
|
||||
done < <(node "$ROOT/scripts/list-versions.mjs")
|
||||
|
||||
if [[ -n "$CONTENT_WORKTREE" ]]; then
|
||||
git worktree remove --force "$CONTENT_WORKTREE"
|
||||
fi
|
||||
|
||||
node "$ROOT/scripts/finalize-docs-site.mjs" "$COMBINED"
|
||||
|
||||
echo "Combined site contents:"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
|
||||
<!-- Build Infra & Packaging dependencies-->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.401" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Testing dependencies -->
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ The C# SDK tracks implementation of MCP spec components using the [modelcontextp
|
|||
|
||||
### Next spec revision
|
||||
|
||||
The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol).
|
||||
The C# SDK's standing plan is to release support for each new MCP specification revision alongside the specification, as it did for the 2026-07-28 revision. Accepted SEPs are tracked on the revision's project board throughout development so implementation is ready when the revision is published. The next revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol).
|
||||
|
||||
### Feedback and end-to-end scenarios
|
||||
|
||||
|
|
|
|||
|
|
@ -846,23 +846,17 @@ internal sealed class StreamableHttpHandler(
|
|||
return false;
|
||||
}
|
||||
|
||||
bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta);
|
||||
|
||||
if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
|
||||
!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta))
|
||||
// Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve
|
||||
// the version from the session or metadata, falling back to legacy handling when neither selects one.
|
||||
if (string.IsNullOrEmpty(protocolVersionHeader) ||
|
||||
McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader))
|
||||
{
|
||||
errorDetail = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(protocolVersionHeader))
|
||||
{
|
||||
errorDetail = CreateHeaderMismatchError(
|
||||
$"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasProtocolVersionMeta)
|
||||
// Header validity was checked first, so only supported modern headers reach this point.
|
||||
if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta))
|
||||
{
|
||||
// Notifications are exempt from the required-_meta rejection: they are fire-and-forget
|
||||
// (a JSON-RPC error response has no recipient), and rejecting them silently drops
|
||||
|
|
|
|||
|
|
@ -77,16 +77,19 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError)
|
||||
{
|
||||
// A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server.
|
||||
// It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion,
|
||||
// -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other
|
||||
// application-level error). Don't fall back to SSE — that would mask the real signal
|
||||
// and surface a misleading "session id required" error from the SSE GET path.
|
||||
// Adopt the Streamable HTTP transport and throw the structured exception so the
|
||||
// connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport
|
||||
// first makes the catch filter below leave the now-owned transport alone.
|
||||
// Adopt it before surfacing the failure so the catch filter leaves the now-owned
|
||||
// transport alone, and never mask the response by attempting deprecated SSE.
|
||||
LogUsingStreamableHttp(_name);
|
||||
ActiveTransport = streamableHttpTransport;
|
||||
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
|
||||
|
||||
if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError))
|
||||
{
|
||||
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
|
||||
}
|
||||
|
||||
// TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response
|
||||
// body and status without consuming the network stream a second time.
|
||||
throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -96,8 +99,6 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
// behavior. Capture the underlying error (status + body) before falling back so that,
|
||||
// if SSE also fails, we can surface the real Streamable HTTP diagnostic to the caller
|
||||
// instead of dropping it on the floor (see https://github.com/modelcontextprotocol/csharp-sdk/issues/1526).
|
||||
LogStreamableHttpFailed(_name, response.StatusCode);
|
||||
|
||||
// This reads the response body a second time for the application/json case, where
|
||||
// TryReadJsonRpcErrorAsync above already read it. HttpContent buffers after the first
|
||||
// read, so this returns the same buffered content and is safe (not a second stream
|
||||
|
|
@ -105,6 +106,26 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
// TryReadJsonRpcErrorAsync returns early on the content type, so there is no double read.
|
||||
var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (IsDiscoverProbeRejection(message, response.StatusCode))
|
||||
{
|
||||
// The server/discover probe is protocol negotiation, not transport detection. A server
|
||||
// predating SEP-2575 rejects the session-less POST with 400 (can't parse the request) or
|
||||
// 404 (requires Mcp-Session-Id on every non-initialize POST) whether it speaks Streamable
|
||||
// HTTP or SSE, so neither status is evidence about which transport to use. McpClientImpl
|
||||
// .ConnectAsync treats exactly these two statuses as "initialize-handshake server" and
|
||||
// immediately retries with initialize on this same transport — and that attempt still
|
||||
// falls back to SSE, so an SSE-only server is reached one POST later rather than not at
|
||||
// all. Attempting SSE here instead spends a GET whose result is discarded on every
|
||||
// connect to a Streamable-HTTP-only server that predates SEP-2575, and logs a "falling
|
||||
// back to SSE transport" line that misreports settled protocol negotiation as a failure.
|
||||
LogSkippingSseFallbackForDiscoverProbe(_name, response.StatusCode);
|
||||
|
||||
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
|
||||
throw streamableHttpError;
|
||||
}
|
||||
|
||||
LogStreamableHttpFailed(_name, response.StatusCode);
|
||||
|
||||
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
|
||||
await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
|
@ -119,6 +140,15 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the failed request was the SEP-2575 <c>server/discover</c> probe and the
|
||||
/// status is one of the two <see cref="McpClientImpl"/> already reads as "this server requires the initialize
|
||||
/// handshake", meaning the SSE fallback cannot contribute anything the initialize retry won't.
|
||||
/// </summary>
|
||||
private static bool IsDiscoverProbeRejection(JsonRpcMessage message, HttpStatusCode statusCode) =>
|
||||
statusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound &&
|
||||
message is JsonRpcRequest { Method: RequestMethods.ServerDiscover };
|
||||
|
||||
private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.KnownSessionId is not null)
|
||||
|
|
@ -181,6 +211,9 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
|
||||
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} server/discover probe rejected with status code {StatusCode}; skipping the SSE fallback so the initialize handshake is attempted instead.")]
|
||||
private partial void LogSkippingSseFallbackForDiscoverProbe(string endpointName, HttpStatusCode statusCode);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
|
||||
private partial void LogUsingStreamableHttp(string endpointName);
|
||||
|
||||
|
|
|
|||
|
|
@ -367,14 +367,6 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
// fallback): falling back to initialize wouldn't fix a malformed envelope.
|
||||
throw;
|
||||
}
|
||||
catch (McpProtocolException ex) when (
|
||||
ex.ErrorCode == McpErrorCode.InvalidRequest &&
|
||||
ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal))
|
||||
{
|
||||
// Local transport validation: a 2026-07-28+ response must not carry HTTP session state.
|
||||
// This is not evidence of an initialize-handshake server, so do not fall back.
|
||||
throw;
|
||||
}
|
||||
catch (McpProtocolException)
|
||||
{
|
||||
// Per spec PR #2844, the fallback MUST NOT be keyed to a single error code.
|
||||
|
|
|
|||
|
|
@ -80,8 +80,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
// for robustness. Servers occasionally emit them with 4xx codes other than 400.
|
||||
if (!response.IsSuccessStatusCode &&
|
||||
await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError &&
|
||||
(response.StatusCode == HttpStatusCode.BadRequest ||
|
||||
IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code)))
|
||||
ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError))
|
||||
{
|
||||
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
|
||||
}
|
||||
|
|
@ -89,10 +88,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) =>
|
||||
code is McpErrorCode.UnsupportedProtocolVersion
|
||||
or McpErrorCode.MissingRequiredClientCapability
|
||||
or McpErrorCode.HeaderMismatch;
|
||||
internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) =>
|
||||
statusCode == HttpStatusCode.BadRequest ||
|
||||
(McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion
|
||||
or McpErrorCode.MissingRequiredClientCapability
|
||||
or McpErrorCode.HeaderMismatch;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a JSON-RPC error envelope from an <c>application/json</c> response body, returning
|
||||
|
|
|
|||
|
|
@ -401,14 +401,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
// Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any
|
||||
// filters run so they (and downstream handlers) can read client info / capabilities /
|
||||
// protocol version / log level without re-parsing.
|
||||
if (_isServer && message is JsonRpcRequest incomingRequest)
|
||||
{
|
||||
PopulateContextFromMeta(incomingRequest);
|
||||
}
|
||||
|
||||
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
|
||||
string method = GetMethodName(message);
|
||||
|
||||
|
|
@ -544,65 +536,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the 2026-07-28 protocol's per-request <c>_meta</c> fields off the request and projects them onto
|
||||
/// <see cref="JsonRpcMessage.Context"/> so they're available without re-parsing throughout the pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per SEP-2575 the keys are <c>io.modelcontextprotocol/protocolVersion</c>,
|
||||
/// <c>/clientInfo</c>, <c>/clientCapabilities</c>, and (optional) <c>/logLevel</c>. Any field
|
||||
/// that's already set on the context (e.g., <see cref="JsonRpcMessageContext.ProtocolVersion"/>
|
||||
/// populated by the HTTP transport from the <c>MCP-Protocol-Version</c> header) is left alone
|
||||
/// unless explicitly overwritten by a non-null value parsed here.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects the 2026-07-28 protocol's per-request <c>_meta</c> fields into an outgoing request.
|
||||
/// Protocol version and client info overwrite any existing values; client capabilities are merged
|
||||
|
|
@ -962,9 +895,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
tags.Add("network.protocol.name", "http");
|
||||
}
|
||||
|
||||
if (NegotiatedProtocolVersion is not null)
|
||||
string? protocolVersion = (message as JsonRpcRequest)?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion;
|
||||
if (protocolVersion is not null)
|
||||
{
|
||||
tags.Add("mcp.protocol.version", NegotiatedProtocolVersion);
|
||||
tags.Add("mcp.protocol.version", protocolVersion);
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
|
|
|
|||
|
|
@ -88,13 +88,12 @@ public sealed class JsonRpcMessageContext
|
|||
public string? RoutingName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protocol version from the transport-level header (e.g. <c>Mcp-Protocol-Version</c>)
|
||||
/// that accompanied this JSON-RPC message.
|
||||
/// Gets or sets the authoritative protocol version for this JSON-RPC message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the <c>initialize</c>
|
||||
/// handshake because each request creates a new server instance. This property allows the transport layer
|
||||
/// to flow the protocol version header so the server can determine client capabilities.
|
||||
/// The transport may populate this from a header such as <c>Mcp-Protocol-Version</c>. For modern revisions,
|
||||
/// the server validates and projects the matching per-request <c>_meta</c> value. Under an established legacy
|
||||
/// revision, future reserved metadata remains opaque and does not establish or change the negotiated session version.
|
||||
/// </remarks>
|
||||
public string? ProtocolVersion { get; set; }
|
||||
|
||||
|
|
@ -104,7 +103,8 @@ public sealed class JsonRpcMessageContext
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>initialize</c> handshake.
|
||||
/// the server uses this in lieu of the value previously captured during the <c>initialize</c> handshake. Future reserved
|
||||
/// metadata remains opaque under legacy revisions, which continue to use their initialized identity.
|
||||
/// </remarks>
|
||||
public Implementation? ClientInfo { get; set; }
|
||||
|
||||
|
|
@ -114,7 +114,9 @@ public sealed class JsonRpcMessageContext
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client
|
||||
/// capabilities from previous requests; the authoritative value is the one declared on each request.
|
||||
/// capabilities from previous modern requests; the authoritative value is the one declared on each request.
|
||||
/// Future reserved metadata remains opaque under legacy revisions, which continue to use the capabilities
|
||||
/// negotiated during initialization.
|
||||
/// </remarks>
|
||||
public ClientCapabilities? ClientCapabilities { get; set; }
|
||||
|
||||
|
|
@ -124,8 +126,8 @@ public sealed class JsonRpcMessageContext
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Replaces the legacy
|
||||
/// <see cref="RequestMethods.LoggingSetLevel"/> RPC. When absent, the server MUST NOT emit log notifications
|
||||
/// for the request.
|
||||
/// <see cref="RequestMethods.LoggingSetLevel"/> 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.
|
||||
/// </remarks>
|
||||
public LoggingLevel? LogLevel { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
|
|||
{
|
||||
get
|
||||
{
|
||||
// In stateless transport mode, a single request does not have a persistent bidirectional channel.
|
||||
// Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the
|
||||
// capability gates rely on a null ClientCapabilities value to report that unsupported-state path.
|
||||
if (!server.HasStatefulTransport())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request)
|
||||
// and must not be inferred from prior requests. Missing per-request capabilities therefore means
|
||||
// "no declared capabilities for this request", represented by an empty object. A fresh instance is
|
||||
|
|
@ -42,11 +34,13 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
|
|||
}
|
||||
|
||||
// Legacy protocol behavior uses session-scoped capabilities established during initialize (or
|
||||
// pre-populated migration data), so ignore per-request values and return the server session state.
|
||||
// pre-populated migration data). Future reserved metadata remains opaque under legacy semantics.
|
||||
return server.ClientCapabilities;
|
||||
}
|
||||
}
|
||||
|
||||
internal override bool SupportsServerToClientRequests => server.SupportsServerToClientRequests;
|
||||
|
||||
public override Implementation? ClientInfo
|
||||
{
|
||||
get
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
internal virtual Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => null;
|
||||
|
||||
internal virtual bool SupportsServerToClientRequests => true;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor.
|
||||
/// </summary>
|
||||
|
|
@ -555,26 +557,26 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
private void ThrowIfSamplingUnsupported()
|
||||
{
|
||||
if (!SupportsServerToClientRequests)
|
||||
{
|
||||
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
if (ClientCapabilities?.Sampling is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Sampling is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support sampling.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfRootsUnsupported()
|
||||
{
|
||||
if (!SupportsServerToClientRequests)
|
||||
{
|
||||
throw new InvalidOperationException("Roots are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
if (ClientCapabilities?.Roots is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Roots are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support roots.");
|
||||
}
|
||||
}
|
||||
|
|
@ -615,12 +617,12 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
if (!SupportsServerToClientRequests)
|
||||
{
|
||||
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
var elicitationCapability = ClientCapabilities.Elicitation;
|
||||
var elicitationCapability = ClientCapabilities?.Elicitation;
|
||||
if (elicitationCapability is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support elicitation requests.");
|
||||
|
|
|
|||
|
|
@ -32,12 +32,11 @@ public abstract partial class McpServer : McpSession
|
|||
/// the <c>Server</c> property of the <see cref="RequestContext{TParams}"/> passed to a handler; on the
|
||||
/// root <see cref="McpServer"/> (for example one constructed manually over a
|
||||
/// <see cref="System.IO.Stream"/>) it is <see langword="null"/>.
|
||||
/// It is also <see langword="null"/> in stateless transport mode, where server-to-client requests are
|
||||
/// unsupported.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Server implementations can check these capabilities to determine which features
|
||||
/// are available when interacting with the client.
|
||||
/// This property reports capabilities declared by the client. Their presence does not guarantee that the
|
||||
/// transport supports server-to-client requests. Methods such as sampling, roots, and elicitation reject
|
||||
/// those requests when the transport cannot safely deliver them, including in stateless mode.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract ClientCapabilities? ClientCapabilities { get; }
|
||||
|
|
|
|||
|
|
@ -33,14 +33,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
|
||||
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
|
||||
private static readonly string[] s_perRequestMetadataKeys =
|
||||
[
|
||||
MetaKeys.ProtocolVersion,
|
||||
MetaKeys.ClientInfo,
|
||||
MetaKeys.ClientCapabilities,
|
||||
MetaKeys.LogLevel,
|
||||
];
|
||||
|
||||
// Track MRTR handler tasks using the same inFlightCount + TCS pattern as
|
||||
// McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself.
|
||||
private int _mrtrInFlightCount = 1;
|
||||
|
|
@ -122,7 +114,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// 2026-07-28+ client) is decided per response in GetAdvertisedCapabilities rather than cleared here,
|
||||
// because the same ServerCapabilities feeds both the legacy initialize handshake (which can never
|
||||
// deliver it) and server/discover (which can, given a custom handler).
|
||||
if (HasStatefulTransport())
|
||||
if (HasStatefulTransport)
|
||||
{
|
||||
Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification);
|
||||
Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification);
|
||||
|
|
@ -160,124 +152,46 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
/// <summary>
|
||||
/// Wraps <paramref name="inner"/> so that, for every JSON-RPC request, a built-in filter first
|
||||
/// synchronizes server-side state (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientInfo"/>)
|
||||
/// from the per-request <c>_meta</c> values projected onto <see cref="JsonRpcMessageContext"/> and
|
||||
/// validates the per-request protocol version, before delegating to the user-supplied incoming filters.
|
||||
/// classifies and projects modern per-request <c>_meta</c>, synchronizes server-side state
|
||||
/// (<see cref="_negotiatedProtocolVersion"/>, <see cref="_clientInfo"/>), and validates protocol
|
||||
/// boundaries before delegating to user-supplied incoming filters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Under the 2026-07-28 protocol revision (SEP-2575) there is no <c>initialize</c> 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 <see cref="DestinationBoundMcpServer"/> and are
|
||||
/// not read from server-wide state by request handlers. The shared <see cref="_clientInfo"/> write below is
|
||||
/// best-effort and used only to derive the session endpoint name for logging/telemetry. For initialize-handshake
|
||||
/// clients the per-request values are absent and the built-in filter is a no-op (the values were captured during
|
||||
/// the initialize handler).
|
||||
/// best-effort and used only to derive the session endpoint name for logging/telemetry. Under initialize-handshake
|
||||
/// revisions, reserved per-request metadata is opaque and is not parsed or projected. Modern envelopes remain
|
||||
/// strictly parsed.
|
||||
/// </remarks>
|
||||
private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner)
|
||||
{
|
||||
JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) =>
|
||||
{
|
||||
if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest)
|
||||
if (message is JsonRpcRequest request)
|
||||
{
|
||||
ValidateInitializeRequestBoundary(initializeRequest);
|
||||
}
|
||||
else if (message is JsonRpcRequest request)
|
||||
{
|
||||
var context = request.Context;
|
||||
bool endpointNameNeedsRefresh = false;
|
||||
bool hasProtocolVersionMeta = HasMetaKey(request, MetaKeys.ProtocolVersion);
|
||||
bool hasReservedPerRequestMeta = TryGetPerRequestMetadataKey(request, out var reservedPerRequestMetaKey);
|
||||
|
||||
if (context?.ProtocolVersion is { } protocolVersion)
|
||||
if (request.Method == RequestMethods.Initialize)
|
||||
{
|
||||
bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null;
|
||||
if (protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(protocolVersion);
|
||||
}
|
||||
|
||||
// Per SEP-2575, the server MUST reject any request whose per-request
|
||||
// _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions
|
||||
// with an UnsupportedProtocolVersionError (-32022) carrying the supported list.
|
||||
if (!_supportedProtocolVersions.Contains(protocolVersion))
|
||||
{
|
||||
var supportedVersions =
|
||||
hasProtocolVersionMeta && _perRequestMetadataProtocolVersions.Length > 0 ?
|
||||
_perRequestMetadataProtocolVersions :
|
||||
_supportedProtocolVersions;
|
||||
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: protocolVersion,
|
||||
supported: supportedVersions);
|
||||
}
|
||||
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion))
|
||||
{
|
||||
ValidateRequiredPerRequestMetadata(
|
||||
protocolVersion,
|
||||
hasProtocolVersionMeta,
|
||||
context.ClientCapabilities is not null);
|
||||
}
|
||||
else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion))
|
||||
{
|
||||
if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta)
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: protocolVersion,
|
||||
supported: _perRequestMetadataProtocolVersions,
|
||||
message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata.");
|
||||
}
|
||||
|
||||
if (hasReservedPerRequestMeta)
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(protocolVersion);
|
||||
}
|
||||
ValidateInitializeRequestBoundary(request);
|
||||
}
|
||||
else if (_negotiatedProtocolVersion is null)
|
||||
else
|
||||
{
|
||||
if (request.Method == RequestMethods.ServerDiscover)
|
||||
ReadRequestMetadata(request);
|
||||
ValidateRequestMethodBoundary(request);
|
||||
|
||||
var context = request.Context;
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(context?.ProtocolVersion ?? _negotiatedProtocolVersion) &&
|
||||
context?.ClientInfo is { } clientInfo &&
|
||||
(_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) ||
|
||||
!string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.",
|
||||
McpErrorCode.InvalidParams);
|
||||
// Modern handlers resolve client info request-scoped through DestinationBoundMcpServer.
|
||||
// This shared write is only for endpoint logging.
|
||||
_clientInfo = clientInfo;
|
||||
UpdateEndpointNameWithClientInfo();
|
||||
_sessionHandler.EndpointName = _endpointName;
|
||||
}
|
||||
|
||||
if (hasReservedPerRequestMeta)
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey);
|
||||
}
|
||||
}
|
||||
else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta)
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey);
|
||||
}
|
||||
|
||||
ValidateRequestMethodBoundary(request);
|
||||
|
||||
if (context?.ClientInfo is { } clientInfo &&
|
||||
(_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) ||
|
||||
!string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal)))
|
||||
{
|
||||
// This shared write is best-effort and used only to derive the session endpoint name for
|
||||
// logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions:
|
||||
// DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from
|
||||
// the per-request _meta so concurrent requests never observe each other's values. Under a
|
||||
// draft stateful session with differing per-request client info, the last writer wins here,
|
||||
// which only affects the logged endpoint name and never the request-scoped values handlers see.
|
||||
_clientInfo = clientInfo;
|
||||
endpointNameNeedsRefresh = true;
|
||||
}
|
||||
|
||||
if (endpointNameNeedsRefresh)
|
||||
{
|
||||
UpdateEndpointNameWithClientInfo();
|
||||
_sessionHandler.EndpointName = _endpointName;
|
||||
}
|
||||
}
|
||||
else if (message is JsonRpcNotification notification)
|
||||
|
|
@ -291,56 +205,158 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return next => metaReadingFilter(inner(next));
|
||||
}
|
||||
|
||||
private static void ValidateRequiredPerRequestMetadata(
|
||||
string protocolVersion,
|
||||
bool hasProtocolVersionMeta,
|
||||
bool hasClientCapabilitiesMeta)
|
||||
private void ReadRequestMetadata(JsonRpcRequest request)
|
||||
{
|
||||
if (!hasProtocolVersionMeta)
|
||||
string? transportProtocolVersion = request.Context?.ProtocolVersion;
|
||||
|
||||
// An established legacy session or supported legacy transport header selects initialize-handshake
|
||||
// semantics. Continue validating an authoritative transport version, but treat future body metadata
|
||||
// as opaque under the negotiated legacy revision.
|
||||
if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) ||
|
||||
McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion))
|
||||
{
|
||||
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion);
|
||||
if (transportProtocolVersion is not null)
|
||||
{
|
||||
if (!_supportedProtocolVersions.Contains(transportProtocolVersion))
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: transportProtocolVersion,
|
||||
supported: _supportedProtocolVersions);
|
||||
}
|
||||
|
||||
SetNegotiatedProtocolVersion(transportProtocolVersion);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// clientInfo is optional: requests whose _meta omits it are served, not rejected.
|
||||
JsonObject? meta = request.Params is JsonObject paramsObj ? paramsObj["_meta"] as JsonObject : null;
|
||||
// An unreadable value cannot select a protocol; only the modern path requires a usable version.
|
||||
string? metadataProtocolVersion =
|
||||
meta?[MetaKeys.ProtocolVersion] is JsonValue value && value.TryGetValue(out string? version) ? version : null;
|
||||
|
||||
if (!hasClientCapabilitiesMeta)
|
||||
if (transportProtocolVersion is not null &&
|
||||
metadataProtocolVersion is not null &&
|
||||
!string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal))
|
||||
{
|
||||
ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities);
|
||||
throw new McpProtocolException(
|
||||
$"Header mismatch: the per-request _meta protocol version '{metadataProtocolVersion}' does not match the MCP-Protocol-Version header value '{transportProtocolVersion}'.",
|
||||
McpErrorCode.HeaderMismatch);
|
||||
}
|
||||
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ||
|
||||
transportProtocolVersion is not null ||
|
||||
(metadataProtocolVersion is not null && !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion)) ||
|
||||
_initializeHandshakeProtocolVersions.Length == 0)
|
||||
{
|
||||
if (metadataProtocolVersion is null)
|
||||
{
|
||||
if (transportProtocolVersion is not null &&
|
||||
!_supportedProtocolVersions.Contains(transportProtocolVersion))
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: transportProtocolVersion,
|
||||
supported: _supportedProtocolVersions);
|
||||
}
|
||||
|
||||
throw MissingPerRequestMetadata(
|
||||
transportProtocolVersion ?? _negotiatedProtocolVersion ?? _perRequestMetadataProtocolVersions[0],
|
||||
MetaKeys.ProtocolVersion);
|
||||
}
|
||||
|
||||
if (!_supportedProtocolVersions.Contains(metadataProtocolVersion))
|
||||
{
|
||||
throw new UnsupportedProtocolVersionException(
|
||||
requested: metadataProtocolVersion,
|
||||
supported: _perRequestMetadataProtocolVersions.Length > 0
|
||||
? _perRequestMetadataProtocolVersions
|
||||
: _supportedProtocolVersions);
|
||||
}
|
||||
|
||||
// Reject version changes before parsing, but establish a new version only after parsing succeeds.
|
||||
bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null;
|
||||
if (protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(metadataProtocolVersion);
|
||||
}
|
||||
|
||||
ProjectModernMetadata(request, meta!, metadataProtocolVersion);
|
||||
|
||||
if (!protocolVersionAlreadyEstablished)
|
||||
{
|
||||
SetNegotiatedProtocolVersion(metadataProtocolVersion);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.Method == RequestMethods.ServerDiscover)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.",
|
||||
McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
// With no established era, warn in case a modern peer accidentally fell back to legacy handling.
|
||||
if (metadataProtocolVersion is null && meta?.ContainsKey(MetaKeys.ProtocolVersion) is true)
|
||||
{
|
||||
LogIgnoredUnreadableProtocolVersionMetadata(_endpointName, request.Method);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) =>
|
||||
throw new McpProtocolException(
|
||||
private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta, string protocolVersion)
|
||||
{
|
||||
if (!meta.ContainsKey(MetaKeys.ClientCapabilities))
|
||||
{
|
||||
throw MissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities);
|
||||
}
|
||||
|
||||
var context = request.Context ??= new();
|
||||
context.ProtocolVersion = protocolVersion;
|
||||
context.ClientInfo = meta[MetaKeys.ClientInfo] is JsonNode clientInfoNode
|
||||
? DeserializeModernMetadata(
|
||||
clientInfoNode,
|
||||
McpJsonUtilities.JsonContext.Default.Implementation,
|
||||
MetaKeys.ClientInfo)
|
||||
: null;
|
||||
context.ClientCapabilities = meta[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode
|
||||
? DeserializeModernMetadata(
|
||||
clientCapabilitiesNode,
|
||||
McpJsonUtilities.JsonContext.Default.ClientCapabilities,
|
||||
MetaKeys.ClientCapabilities)
|
||||
: throw InvalidMetadata(MetaKeys.ClientCapabilities);
|
||||
context.LogLevel = meta[MetaKeys.LogLevel] is JsonNode logLevelNode
|
||||
? DeserializeModernMetadata(
|
||||
logLevelNode,
|
||||
McpJsonUtilities.JsonContext.Default.LoggingLevel,
|
||||
MetaKeys.LogLevel)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static T DeserializeModernMetadata<T>(JsonNode node, JsonTypeInfo<T> typeInfo, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
T? value = JsonSerializer.Deserialize(node, typeInfo);
|
||||
return value is not null ? value : throw new JsonException();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"The per-request metadata key '_meta/{key}' has an invalid value.",
|
||||
ex,
|
||||
McpErrorCode.InvalidParams);
|
||||
}
|
||||
}
|
||||
|
||||
private static McpProtocolException InvalidMetadata(string key) =>
|
||||
new($"The per-request metadata key '_meta/{key}' has an invalid value.", McpErrorCode.InvalidParams);
|
||||
|
||||
private static McpProtocolException MissingPerRequestMetadata(string protocolVersion, string key) =>
|
||||
new(
|
||||
$"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.",
|
||||
McpErrorCode.InvalidParams);
|
||||
|
||||
private static void ThrowReservedPerRequestMetadata(string? requestedProtocolVersion, string key) =>
|
||||
throw new McpProtocolException(
|
||||
requestedProtocolVersion is null
|
||||
? $"The reserved per-request metadata key '_meta/{key}' requires a protocol version that uses per-request metadata."
|
||||
: $"The reserved per-request metadata key '_meta/{key}' is not valid with protocol version '{requestedProtocolVersion}'.",
|
||||
McpErrorCode.InvalidRequest);
|
||||
|
||||
private static bool TryGetPerRequestMetadataKey(JsonRpcRequest request, out string key)
|
||||
{
|
||||
foreach (var candidate in s_perRequestMetadataKeys)
|
||||
{
|
||||
if (HasMetaKey(request, candidate))
|
||||
{
|
||||
key = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
key = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasMetaKey(JsonRpcRequest request, string key) =>
|
||||
request.Params is JsonObject paramsObj &&
|
||||
paramsObj["_meta"] is JsonObject metaObj &&
|
||||
metaObj.ContainsKey(key);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the server identity to every successful result on per-request-metadata protocol revisions.
|
||||
/// The filter runs before application filters so they can inspect or intentionally remove the metadata.
|
||||
|
|
@ -372,12 +388,17 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
private void ValidateInitializeRequestBoundary(JsonRpcRequest request)
|
||||
{
|
||||
// Per-request-metadata revisions (SEP-2575) removed the initialize handshake entirely:
|
||||
// the request is for a method the server does not implement on that revision.
|
||||
if (McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion))
|
||||
// Modern revisions removed initialize. An established modern session is authoritative even when
|
||||
// the new request has no version header; a failed discovery probe does not establish a version.
|
||||
string? modernProtocolVersion =
|
||||
McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion) ? request.Context!.ProtocolVersion :
|
||||
McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ? _negotiatedProtocolVersion :
|
||||
null;
|
||||
|
||||
if (modernProtocolVersion is not null)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.",
|
||||
$"Method '{RequestMethods.Initialize}' is not available on protocol version '{modernProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.",
|
||||
McpErrorCode.MethodNotFound);
|
||||
}
|
||||
|
||||
|
|
@ -389,23 +410,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
supported: _initializeHandshakeProtocolVersions,
|
||||
message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake.");
|
||||
}
|
||||
|
||||
if (TryGetPerRequestMetadataKey(request, out var key))
|
||||
{
|
||||
ThrowReservedPerRequestMetadata(TryGetStringParam(request, "protocolVersion"), key);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetStringParam(JsonRpcRequest request, string propertyName)
|
||||
{
|
||||
if (request.Params is JsonObject paramsObj &&
|
||||
paramsObj[propertyName] is JsonValue value &&
|
||||
value.TryGetValue(out string? result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion)
|
||||
|
|
@ -511,13 +515,17 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
public ServerCapabilities ServerCapabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <see cref="ServerCapabilities"/> to advertise in a specific response, suppressing the
|
||||
/// <c>listChanged</c> flags the server has no way to honor.
|
||||
/// Returns the <see cref="ServerCapabilities"/> to advertise in a specific response, suppressing
|
||||
/// capabilities that are not available on that response's protocol path.
|
||||
/// </summary>
|
||||
/// <param name="listenStreamCanDeliverListChanged">
|
||||
/// <see langword="true"/> when the client this response targets can receive <c>*/list_changed</c>
|
||||
/// notifications over a <c>subscriptions/listen</c> stream.
|
||||
/// </param>
|
||||
/// <param name="includeDeprecatedLogging">
|
||||
/// <see langword="true"/> for legacy initialize responses that support <c>logging/setLevel</c>;
|
||||
/// <see langword="false"/> for modern discover responses, where that method is unavailable.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// A stateless HTTP server has no session-wide channel to push unsolicited <c>*/list_changed</c>
|
||||
/// notifications. It can only deliver them over a <c>subscriptions/listen</c> stream, which requires both
|
||||
|
|
@ -525,11 +533,15 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
/// <see cref="McpServerHandlers.SubscriptionsListenHandler"/> 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 <c>listChanged</c> flags are dropped so the server never advertises a capability it cannot
|
||||
/// deliver. Everything else (for example <c>resources.subscribe</c>) is preserved.
|
||||
/// deliver. The deprecated logging capability is likewise omitted from modern discovery because this SDK
|
||||
/// rejects the legacy <c>logging/setLevel</c> method on that path. Everything else is preserved.
|
||||
/// </remarks>
|
||||
private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged)
|
||||
private ServerCapabilities GetAdvertisedCapabilities(
|
||||
bool listenStreamCanDeliverListChanged,
|
||||
bool includeDeprecatedLogging)
|
||||
{
|
||||
if (HasStatefulTransport() || listenStreamCanDeliverListChanged)
|
||||
bool includeListChanged = HasStatefulTransport || listenStreamCanDeliverListChanged;
|
||||
if (includeListChanged && includeDeprecatedLogging)
|
||||
{
|
||||
return ServerCapabilities;
|
||||
}
|
||||
|
|
@ -539,20 +551,32 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return new ServerCapabilities
|
||||
{
|
||||
Experimental = ServerCapabilities.Experimental,
|
||||
Logging = ServerCapabilities.Logging,
|
||||
Logging = includeDeprecatedLogging ? ServerCapabilities.Logging : null,
|
||||
Completions = ServerCapabilities.Completions,
|
||||
Extensions = ServerCapabilities.Extensions,
|
||||
Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null },
|
||||
Prompts = ServerCapabilities.Prompts is null
|
||||
? null
|
||||
: includeListChanged
|
||||
? ServerCapabilities.Prompts
|
||||
: new PromptsCapability { ListChanged = null },
|
||||
Resources = ServerCapabilities.Resources is { } resources
|
||||
? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
|
||||
? includeListChanged
|
||||
? resources
|
||||
: new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null }
|
||||
: null,
|
||||
Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null },
|
||||
Tools = ServerCapabilities.Tools is null
|
||||
? null
|
||||
: includeListChanged
|
||||
? ServerCapabilities.Tools
|
||||
: new ToolsCapability { ListChanged = null },
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ClientCapabilities? ClientCapabilities => _clientCapabilities;
|
||||
|
||||
internal override bool SupportsServerToClientRequests => HasStatefulTransport;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Implementation? ClientInfo => _clientInfo;
|
||||
|
||||
|
|
@ -688,11 +712,8 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
string negotiatedProtocolVersion = protocolVersion ?? McpProtocolVersions.November2025ProtocolVersion;
|
||||
|
||||
// The initialize handshake is authoritative: it may supersede a protocol version
|
||||
// a prior server/discover probe established on the same connection (the dual-path
|
||||
// fallback path a permissive client takes against an unknown server). Unlike the
|
||||
// per-request 2026-07-28 version - which SetNegotiatedProtocolVersion locks once negotiated -
|
||||
// initialize force-sets the version.
|
||||
// initialize may supersede a legacy transport version. ValidateInitializeRequestBoundary
|
||||
// prevents it from downgrading an established modern session.
|
||||
_negotiatedProtocolVersion = negotiatedProtocolVersion;
|
||||
_sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion;
|
||||
|
||||
|
|
@ -705,7 +726,9 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// The initialize handshake only serves pre-2026-07-28 clients, which cannot open a
|
||||
// subscriptions/listen stream, so a stateless server has no way to deliver list-changed
|
||||
// notifications to them regardless of any custom handler.
|
||||
Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false),
|
||||
Capabilities = GetAdvertisedCapabilities(
|
||||
listenStreamCanDeliverListChanged: false,
|
||||
includeDeprecatedLogging: true),
|
||||
|
||||
// resultType is a 2026-07-28 result field. The initialize handshake is only available on
|
||||
// 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw
|
||||
|
|
@ -738,7 +761,8 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// author supplied a custom handler to own that stream (the built-in stateless handler
|
||||
// grants nothing, so it cannot).
|
||||
Capabilities = GetAdvertisedCapabilities(
|
||||
listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null),
|
||||
listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null,
|
||||
includeDeprecatedLogging: false),
|
||||
Instructions = options.ServerInstructions,
|
||||
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to
|
||||
// the safest values (immediately stale, not shareable) so existing servers keep
|
||||
|
|
@ -841,7 +865,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// request granting no notifications and complete immediately. This runs after protocol
|
||||
// negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the
|
||||
// initialize handshake.
|
||||
if (!HasStatefulTransport())
|
||||
if (!HasStatefulTransport)
|
||||
{
|
||||
var statelessSubscription = new ActiveSubscription(
|
||||
jsonRpcRequest.Id,
|
||||
|
|
@ -2048,7 +2072,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
/// sites where the intent is "the client understands <see cref="InputRequiredResult"/>" rather than
|
||||
/// "the peer speaks the 2026-07-28 or later revision".
|
||||
/// </summary>
|
||||
internal bool ClientSupportsMrtr() => IsJuly2026OrLaterProtocol();
|
||||
private bool ClientSupportsMrtr => IsJuly2026OrLaterProtocol();
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the session is stateful - the same server instance handles
|
||||
|
|
@ -2057,8 +2081,9 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
/// <c>elicitation/create</c> / <c>sampling/createMessage</c> / <c>roots/list</c> to the client and
|
||||
/// retry the handler with the responses.
|
||||
/// </summary>
|
||||
internal bool HasStatefulTransport() =>
|
||||
private bool HasStatefulTransport =>
|
||||
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the given request was negotiated under the 2026-07-28 or later protocol
|
||||
/// revision, derived from the per-request <c>_meta</c>/<c>MCP-Protocol-Version</c> value (so it works
|
||||
|
|
@ -2073,7 +2098,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();
|
||||
public override bool IsMrtrSupported => ClientSupportsMrtr || HasStatefulTransport;
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a handler and catches <see cref="InputRequiredException"/> to convert it to an
|
||||
|
|
@ -2117,7 +2142,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
// If the client natively supports MRTR, serialize and return directly -
|
||||
// the client will drive the retry loop.
|
||||
if (ClientSupportsMrtr())
|
||||
if (ClientSupportsMrtr)
|
||||
{
|
||||
return SerializeInputRequiredResult(inputRequiredResult);
|
||||
}
|
||||
|
|
@ -2125,7 +2150,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// In stateless mode without MRTR, the server can't resolve input requests via
|
||||
// JSON-RPC (no persistent session for server-to-client requests), and the client
|
||||
// won't recognize the InputRequiredResult. This is the one unsupported configuration.
|
||||
if (!HasStatefulTransport())
|
||||
if (!HasStatefulTransport)
|
||||
{
|
||||
throw new McpException(
|
||||
"A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " +
|
||||
|
|
@ -2364,7 +2389,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
// For all other cases - legacy clients, stateless sessions - fall through to the
|
||||
// exception-based path, which transparently resolves InputRequiredException via
|
||||
// legacy JSON-RPC requests when the client doesn't speak MRTR.
|
||||
if (!ClientSupportsMrtr() || !HasStatefulTransport())
|
||||
if (!ClientSupportsMrtr || !HasStatefulTransport)
|
||||
{
|
||||
return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
|
@ -2527,6 +2552,9 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")]
|
||||
private partial void MrtrHandlerError(Exception exception);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} ignored an unreadable '_meta/io.modelcontextprotocol/protocolVersion' value on '{Method}' and selected initialize-handshake semantics. The client may not be spec-compliant.")]
|
||||
private partial void LogIgnoredUnreadableProtocolVersionMetadata(string endpointName, string method);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")]
|
||||
private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ internal sealed class OutgoingRequestInterceptingMcpServer(
|
|||
|
||||
public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities;
|
||||
|
||||
// The interceptor replaces the session's server-to-client channel, so it can deliver requests even
|
||||
// when the underlying transport cannot (for example a stateless HTTP request, where a background task
|
||||
// parks the request in an IMcpTaskStore and the client answers it on a later, unrelated request).
|
||||
internal override bool SupportsServerToClientRequests => true;
|
||||
|
||||
public override Implementation? ClientInfo => server.ClientInfo;
|
||||
|
||||
public override McpServerOptions ServerOptions => server.ServerOptions;
|
||||
|
|
|
|||
|
|
@ -76,9 +76,6 @@ internal sealed partial class StreamableHttpPostTransport(
|
|||
return false;
|
||||
}
|
||||
|
||||
CancellationTokenSource? deferredFlushCts = null;
|
||||
Task? deferredFlushTask = null;
|
||||
bool deferHeaderFlush = false;
|
||||
using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
|
||||
|
|
@ -87,88 +84,33 @@ internal sealed partial class StreamableHttpPostTransport(
|
|||
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
|
||||
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (onResponseStarting is null)
|
||||
else if (onResponseStarting is not null &&
|
||||
McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion))
|
||||
{
|
||||
// If there's no priming write, flush the stream to ensure HTTP response headers are
|
||||
// sent to the client now that the server is ready to process the request.
|
||||
// This prevents HttpClient timeout for long-running requests.
|
||||
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Per-request-metadata protocol revisions map the first JSON-RPC error onto the HTTP
|
||||
// status line. Keep the headers uncommitted until that message arrives so the mapping
|
||||
// cannot depend on how long dispatch takes.
|
||||
}
|
||||
else
|
||||
{
|
||||
deferHeaderFlush = true;
|
||||
// Earlier protocol revisions keep the eager header flush for long-running handlers.
|
||||
// Mark the response as started before flushing because any later JSON-RPC error must
|
||||
// not attempt to change an already committed status line.
|
||||
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
|
||||
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Ensure that we've sent the priming event before processing the incoming request.
|
||||
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (deferHeaderFlush)
|
||||
{
|
||||
// Defer the flush (and the header commit it implies) so the callback can still choose
|
||||
// the HTTP status line for an immediate JSON-RPC error. Start the bounded grace period
|
||||
// only after the request has been queued for dispatch.
|
||||
deferredFlushCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deferredFlushTask = DeferredHeaderFlushAsync(deferredFlushCts.Token);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for the response to be written before returning from the handler.
|
||||
// This keeps the HTTP response open until the final response message is sent.
|
||||
await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (deferredFlushCts is not null)
|
||||
{
|
||||
deferredFlushCts.Cancel();
|
||||
await deferredFlushTask!.ConfigureAwait(false);
|
||||
deferredFlushCts.Dispose();
|
||||
}
|
||||
}
|
||||
// Wait for the response to be written before returning from the handler.
|
||||
// This keeps the HTTP response open until the final response message is sent.
|
||||
await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How long the response-header flush may be deferred waiting for the first response message.</summary>
|
||||
internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the response-starting callback exactly once, immediately before the first write to
|
||||
/// the HTTP response stream, so the HTTP application can still set the response status line.
|
||||
|
|
@ -343,6 +285,4 @@ internal sealed partial class StreamableHttpPostTransport(
|
|||
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")]
|
||||
private partial void LogStoreStreamDisposalFailed(Exception exception);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to flush deferred Streamable HTTP response headers.")]
|
||||
private partial void LogDeferredHeaderFlushFailed(Exception exception);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,14 +216,14 @@ public sealed partial class StreamableHttpServerTransport : ITransport
|
|||
/// This overload additionally reports the first JSON-RPC message written to the response via
|
||||
/// <paramref name="onResponseStarting"/>, 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 <paramref name="onResponseStarting"/> is provided, the eager
|
||||
/// response-header flush that normally precedes request processing is deferred until that first
|
||||
/// message; the callback receives <see langword="null"/> when the first write is not a JSON-RPC
|
||||
/// message (e.g. a resumability priming event).
|
||||
/// codes to HTTP statuses). When <paramref name="onResponseStarting"/> 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
|
||||
/// <see langword="null"/> when the first write is not a JSON-RPC message (e.g. a resumability
|
||||
/// priming event).
|
||||
/// The status line can only be influenced by the FIRST write: when a handler streams a
|
||||
/// notification (e.g. progress) before failing, or runs past the transport's bounded
|
||||
/// header-flush grace window, the status is already committed and a later JSON-RPC error
|
||||
/// rides the committed status.
|
||||
/// notification (e.g. progress) before failing, the status is already committed and a later
|
||||
/// JSON-RPC error rides the committed status.
|
||||
/// </summary>
|
||||
/// <param name="message">The JSON-RPC message to process.</param>
|
||||
/// <param name="responseStream">The response stream to write any JSON-RPC responses to.</param>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
base.Dispose();
|
||||
}
|
||||
|
||||
private async Task StartServerAsync(RequestDelegate handler)
|
||||
private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false)
|
||||
{
|
||||
Builder.Services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
|
|
@ -65,7 +65,14 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
});
|
||||
|
||||
_app = Builder.Build();
|
||||
_app.MapPost("/mcp", handler);
|
||||
if (acceptGet)
|
||||
{
|
||||
_app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler);
|
||||
}
|
||||
else
|
||||
{
|
||||
_app.MapPost("/mcp", handler);
|
||||
}
|
||||
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +309,63 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) :
|
|||
Assert.False(initializeReceived);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var initializeRequests = 0;
|
||||
var sseGetRequests = 0;
|
||||
|
||||
await StartServerAsync(async context =>
|
||||
{
|
||||
if (HttpMethods.IsGet(context.Request.Method))
|
||||
{
|
||||
sseGetRequests++;
|
||||
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
|
||||
return;
|
||||
}
|
||||
|
||||
var message = await JsonSerializer.DeserializeAsync(
|
||||
context.Request.Body,
|
||||
GetJsonTypeInfo<JsonRpcMessage>(),
|
||||
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<HttpRequestException>(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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenTelemetry.Trace;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests;
|
||||
|
||||
|
|
@ -10,6 +12,57 @@ public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStream
|
|||
protected override bool UseStreamableHttp => true;
|
||||
protected override bool Stateless => true;
|
||||
|
||||
// In stateless mode each HTTP request is served by a fresh, un-negotiated McpServer, so the
|
||||
// session-level NegotiatedProtocolVersion is null when the server span is tagged. The tag must
|
||||
// therefore come from the per-request MCP-Protocol-Version header / _meta value. Exercising more
|
||||
// than one version proves the tag tracks the per-request value rather than coincidentally matching
|
||||
// a single hard-coded version (and would regress to an absent tag if the per-request fallback were
|
||||
// removed, since the negotiated value is null at tagging time).
|
||||
[Theory]
|
||||
[InlineData("2025-11-25")]
|
||||
[InlineData("2026-07-28")]
|
||||
public async Task ServerActivity_TagsPerRequestProtocolVersion_InStatelessMode(string protocolVersion)
|
||||
{
|
||||
var activities = new List<Activity>();
|
||||
string? capturedNegotiatedProtocolVersion = null;
|
||||
|
||||
var protocolVersionTool = McpServerTool.Create(
|
||||
(RequestContext<CallToolRequestParams> 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()
|
||||
{
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue