Skip to content

Commit 8922812

Browse files
authored
Merge branch 'main' into skip-sse-fallback-on-discover-probe
2 parents cff927f + 324ccd8 commit 8922812

28 files changed

Lines changed: 1056 additions & 547 deletions

‎.github/release-process.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,7 @@ monitor both and confirm their published outputs:
4949
published release tags and deploys it to
5050
[csharp.sdk.modelcontextprotocol.io](https://csharp.sdk.modelcontextprotocol.io). Verify the new
5151
version appears in the version picker and that its major-version path serves the updated content.
52-
A content-only docs refresh can be run later via manual dispatch with the `docs_ref` input.
52+
A content-only docs refresh can be run later via manual dispatch. Its `docs_source` input defaults
53+
to `release-tags` to mirror a release deployment; select `latest-branches` to refresh every
54+
published major from its current `release/{MAJOR}.x` branch, using `main` for its
55+
matching major.

‎.github/skills/verify-release/SKILL.md‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,14 @@ Report the run's conclusion, accounting for these docs-specific behaviors:
103103
- **Version discovery reads published releases.** For each major version >= 1, the workflow takes
104104
the most recently published non-draft release tagged `v{MAJOR}.*`. A draft release contributes
105105
nothing.
106-
- **Every major is rebuilt.** Each major's docs are built from that major's latest release tag into
107-
its own path (`/v1/`, `/v2/`). A new MAJOR adds a new path; the site root redirects to the newest
108-
release, prereleases included.
106+
- **Every major is rebuilt.** A release-triggered run builds each major from its latest release tag
107+
into its own path (`/v1/`, `/v2/`). A manual dispatch defaults to the same release-tag content,
108+
or can build every major from `release/{MAJOR}.x`, using `main` for its matching major,
109+
by selecting `docs_source=latest-branches`. A new MAJOR adds a new path; the site root redirects
110+
to the newest release, prereleases included.
109111
- **Orchestration comes from `main`.** The scripts and picker assets are always checked out from
110-
`main`, while each version's content comes from its release tag. A docs fix that lives only in a
112+
`main`. Release-triggered and default manual runs use release-tag content; a manual
113+
`docs_source=latest-branches` run uses source-branch content. A docs fix that lives only in a
111114
release branch will not affect orchestration.
112115

113116
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.
164167
gh run rerun {run-id} --failed
165168
```
166169

167-
**Refresh the docs without a new release** — when documentation content needs correcting after the
168-
release, the docs workflow accepts a manual dispatch that rebuilds one major version's content from
169-
an arbitrary ref, without minting a product release:
170+
**Rebuild the released docs** — use the default `release-tags` source to retry a failed Pages
171+
deployment after a release, without minting a product release:
170172

171173
```
172-
gh workflow run docs.yml --field docs_ref={branch-tag-or-commit}
174+
gh workflow run docs.yml --field docs_source=release-tags
173175
```
174176

175-
The ref's major version, read from `src/Directory.Build.props`, must have a published release; the
176-
workflow fails fast if it does not. This replaces only the matching major's HTML — orchestration
177-
and all other versions are unaffected.
177+
**Refresh docs without a new release** — select `latest-branches` to rebuild every published major
178+
from its current source branch (`release/{MAJOR}.x`, using `main` for its matching major):
179+
180+
```
181+
gh workflow run docs.yml --field docs_source=latest-branches
182+
```
183+
184+
The workflow fails instead of silently using a release tag when any major has no matching branch.
178185

179186
## Edge Cases
180187

‎.github/workflows/docs.yml‎

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,23 @@ name: Publish Docs
66
# latest release, by date, for each major >= 1), builds each release tag into
77
# its own sub-path (e.g. /v1/ and /v2/) with make generate-docs, injects a
88
# version-picker widget into each page, adds a root redirect to the default
9-
# version, and deploys the combined site. A manual dispatch can also rebuild
10-
# the matching major-version path from a specified branch, tag, or commit.
9+
# version, and deploys the combined site. A manual dispatch can build every
10+
# major-version path from release tags or current source branches.
1111
# Triggers on release publish and manual dispatch.
1212

1313
on:
1414
release:
1515
types: [published]
1616
workflow_dispatch:
1717
inputs:
18-
docs_ref:
19-
description: Branch, tag, or commit whose docs should refresh its matching major version
20-
required: false
21-
type: string
18+
docs_source:
19+
description: Source for every published major-version path
20+
required: true
21+
default: release-tags
22+
type: choice
23+
options:
24+
- release-tags
25+
- latest-branches
2226

2327
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
2428
permissions:
@@ -86,7 +90,7 @@ jobs:
8690
- name: Build all versions
8791
shell: bash
8892
env:
89-
CONTENT_REF: ${{ inputs.docs_ref }}
93+
USE_SOURCE_BRANCHES: ${{ inputs.docs_source == 'latest-branches' }}
9094
run: |
9195
set -euo pipefail
9296
ROOT="$PWD"
@@ -96,28 +100,43 @@ jobs:
96100
97101
# Orchestration (discovered docs-versions.json, scripts, picker assets)
98102
# always comes from THIS branch. Normally each version's HTML is produced
99-
# by its release tag. A manual docs_ref replaces the matching major's
100-
# HTML with content built from that ref, allowing content-only refreshes
101-
# without minting a product release.
102-
git fetch --tags --force origin
103-
CONTENT_REF="${CONTENT_REF:-}"
104-
CONTENT_WORKTREE=""
105-
CONTENT_SLUG=""
106-
if [[ -n "$CONTENT_REF" ]]; then
107-
CONTENT_WORKTREE="$ROOT/../work-content"
108-
git worktree add --force --detach "$CONTENT_WORKTREE" "$CONTENT_REF"
109-
CONTENT_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$CONTENT_WORKTREE/src/Directory.Build.props")"
110-
if ! node "$ROOT/scripts/list-versions.mjs" | cut -f1 | grep -Fxq "$CONTENT_SLUG"; then
111-
echo "::error::The docs ref '$CONTENT_REF' has major '$CONTENT_SLUG', which has no published release."
112-
exit 1
113-
fi
114-
echo "Refreshing $CONTENT_SLUG docs from $CONTENT_REF"
103+
# by its release tag. A manual dispatch can instead build every major from
104+
# its current source branch.
105+
git fetch --tags --force --prune origin "+refs/heads/*:refs/remotes/origin/*"
106+
USE_SOURCE_BRANCHES="${USE_SOURCE_BRANCHES:-false}"
107+
108+
MAIN_SLUG=""
109+
if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then
110+
MAIN_PROPS="$(mktemp)"
111+
git show refs/remotes/origin/main:src/Directory.Build.props > "$MAIN_PROPS"
112+
MAIN_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$MAIN_PROPS")"
113+
rm -f "$MAIN_PROPS"
114+
echo "Refreshing all published docs from source branches (main serves $MAIN_SLUG)"
115+
else
116+
echo "Refreshing all published docs from release tags"
115117
fi
116118
117119
while IFS=$'\t' read -r slug ref; do
118-
if [[ "$slug" == "$CONTENT_SLUG" ]]; then
119-
echo "::group::Build $slug (from ref $CONTENT_REF)"
120-
wt="$CONTENT_WORKTREE"
120+
if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then
121+
source_branch="release/${slug#v}.x"
122+
if ! git show-ref --verify --quiet "refs/remotes/origin/$source_branch"; then
123+
if [[ "$slug" == "$MAIN_SLUG" ]]; then
124+
source_branch="main"
125+
else
126+
echo "::error::No source branch found for $slug. Expected release/${slug#v}.x, or main with VersionPrefix for $slug."
127+
exit 1
128+
fi
129+
fi
130+
131+
echo "::group::Build $slug (from branch $source_branch)"
132+
wt="$ROOT/../work-$slug"
133+
git worktree add --force --detach "$wt" "refs/remotes/origin/$source_branch"
134+
source_slug="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$wt/src/Directory.Build.props")"
135+
if [[ "$source_slug" != "$slug" ]]; then
136+
git worktree remove --force "$wt"
137+
echo "::error::Source branch '$source_branch' has major '$source_slug', not '$slug'."
138+
exit 1
139+
fi
121140
else
122141
echo "::group::Build $slug (from tag $ref)"
123142
wt="$ROOT/../work-$slug"
@@ -130,16 +149,10 @@ jobs:
130149
cp -a "$wt/artifacts/_site/." "$COMBINED/$slug/"
131150
node "$ROOT/scripts/inject-version-picker.mjs" "$COMBINED/$slug" "$slug" --base /
132151
133-
if [[ "$wt" != "$CONTENT_WORKTREE" ]]; then
134-
git worktree remove --force "$wt"
135-
fi
152+
git worktree remove --force "$wt"
136153
echo "::endgroup::"
137154
done < <(node "$ROOT/scripts/list-versions.mjs")
138155
139-
if [[ -n "$CONTENT_WORKTREE" ]]; then
140-
git worktree remove --force "$CONTENT_WORKTREE"
141-
fi
142-
143156
node "$ROOT/scripts/finalize-docs-site.mjs" "$COMBINED"
144157
145158
echo "Combined site contents:"

‎Directory.Packages.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858

5959
<!-- Build Infra & Packaging dependencies-->
6060
<ItemGroup>
61-
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
61+
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.401" />
6262
</ItemGroup>
6363

6464
<!-- Testing dependencies -->

‎docs/roadmap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The C# SDK tracks implementation of MCP spec components using the [modelcontextp
1212

1313
### Next spec revision
1414

15-
The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol).
15+
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).
1616

1717
### Feedback and end-to-end scenarios
1818

‎src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -846,23 +846,17 @@ private static bool ValidateProtocolVersionEnvelope(
846846
return false;
847847
}
848848

849-
bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta);
850-
851-
if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) &&
852-
!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta))
849+
// Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve
850+
// the version from the session or metadata, falling back to legacy handling when neither selects one.
851+
if (string.IsNullOrEmpty(protocolVersionHeader) ||
852+
McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader))
853853
{
854854
errorDetail = null;
855855
return true;
856856
}
857857

858-
if (string.IsNullOrEmpty(protocolVersionHeader))
859-
{
860-
errorDetail = CreateHeaderMismatchError(
861-
$"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version.");
862-
return false;
863-
}
864-
865-
if (!hasProtocolVersionMeta)
858+
// Header validity was checked first, so only supported modern headers reach this point.
859+
if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta))
866860
{
867861
// Notifications are exempt from the required-_meta rejection: they are fire-and-forget
868862
// (a JSON-RPC error response has no recipient), and rejecting them silently drops

‎src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,19 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
7777
else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError)
7878
{
7979
// A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server.
80-
// It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion,
81-
// -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other
82-
// application-level error). Don't fall back to SSE — that would mask the real signal
83-
// and surface a misleading "session id required" error from the SSE GET path.
84-
// Adopt the Streamable HTTP transport and throw the structured exception so the
85-
// connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport
86-
// first makes the catch filter below leave the now-owned transport alone.
80+
// Adopt it before surfacing the failure so the catch filter leaves the now-owned
81+
// transport alone, and never mask the response by attempting deprecated SSE.
8782
LogUsingStreamableHttp(_name);
8883
ActiveTransport = streamableHttpTransport;
89-
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
84+
85+
if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError))
86+
{
87+
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
88+
}
89+
90+
// TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response
91+
// body and status without consuming the network stream a second time.
92+
throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
9093
}
9194
else
9295
{

‎src/ModelContextProtocol.Core/Client/McpClientImpl.cs‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -367,14 +367,6 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
367367
// fallback): falling back to initialize wouldn't fix a malformed envelope.
368368
throw;
369369
}
370-
catch (McpProtocolException ex) when (
371-
ex.ErrorCode == McpErrorCode.InvalidRequest &&
372-
ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal))
373-
{
374-
// Local transport validation: a 2026-07-28+ response must not carry HTTP session state.
375-
// This is not evidence of an initialize-handshake server, so do not fall back.
376-
throw;
377-
}
378370
catch (McpProtocolException)
379371
{
380372
// Per spec PR #2844, the fallback MUST NOT be keyed to a single error code.

‎src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,19 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
8080
// for robustness. Servers occasionally emit them with 4xx codes other than 400.
8181
if (!response.IsSuccessStatusCode &&
8282
await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError &&
83-
(response.StatusCode == HttpStatusCode.BadRequest ||
84-
IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code)))
83+
ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError))
8584
{
8685
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
8786
}
8887

8988
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
9089
}
9190

92-
private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) =>
93-
code is McpErrorCode.UnsupportedProtocolVersion
94-
or McpErrorCode.MissingRequiredClientCapability
95-
or McpErrorCode.HeaderMismatch;
91+
internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) =>
92+
statusCode == HttpStatusCode.BadRequest ||
93+
(McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion
94+
or McpErrorCode.MissingRequiredClientCapability
95+
or McpErrorCode.HeaderMismatch;
9696

9797
/// <summary>
9898
/// Reads a JSON-RPC error envelope from an <c>application/json</c> response body, returning

‎src/ModelContextProtocol.Core/McpSessionHandler.cs‎

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -401,14 +401,6 @@ private static async Task<ClientCompletionDetails> GetCompletionDetailsAsync(Tas
401401

402402
private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken)
403403
{
404-
// Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any
405-
// filters run so they (and downstream handlers) can read client info / capabilities /
406-
// protocol version / log level without re-parsing.
407-
if (_isServer && message is JsonRpcRequest incomingRequest)
408-
{
409-
PopulateContextFromMeta(incomingRequest);
410-
}
411-
412404
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
413405
string method = GetMethodName(message);
414406

@@ -544,65 +536,6 @@ await SendMessageAsync(new JsonRpcResponse
544536
return result;
545537
}
546538

547-
/// <summary>
548-
/// Reads the 2026-07-28 protocol's per-request <c>_meta</c> fields off the request and projects them onto
549-
/// <see cref="JsonRpcMessage.Context"/> so they're available without re-parsing throughout the pipeline.
550-
/// </summary>
551-
/// <remarks>
552-
/// Per SEP-2575 the keys are <c>io.modelcontextprotocol/protocolVersion</c>,
553-
/// <c>/clientInfo</c>, <c>/clientCapabilities</c>, and (optional) <c>/logLevel</c>. Any field
554-
/// that's already set on the context (e.g., <see cref="JsonRpcMessageContext.ProtocolVersion"/>
555-
/// populated by the HTTP transport from the <c>MCP-Protocol-Version</c> header) is left alone
556-
/// unless explicitly overwritten by a non-null value parsed here.
557-
/// </remarks>
558-
internal static void PopulateContextFromMeta(JsonRpcRequest request)
559-
{
560-
if (request.Params is not JsonObject paramsObj)
561-
{
562-
return;
563-
}
564-
565-
if (paramsObj["_meta"] is not JsonObject metaObj)
566-
{
567-
return;
568-
}
569-
570-
var context = request.Context ??= new JsonRpcMessageContext();
571-
572-
if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion &&
573-
protocolVersion.TryGetValue(out string? protocolVersionValue))
574-
{
575-
// If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already
576-
// populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with
577-
// -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks),
578-
// which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather
579-
// than mistaking it for an initialize-handshake server and falling back to initialize.
580-
if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal))
581-
{
582-
throw new McpProtocolException(
583-
$"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.",
584-
McpErrorCode.HeaderMismatch);
585-
}
586-
587-
context.ProtocolVersion = protocolVersionValue;
588-
}
589-
590-
if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode)
591-
{
592-
context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation);
593-
}
594-
595-
if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode)
596-
{
597-
context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities);
598-
}
599-
600-
if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode)
601-
{
602-
context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel);
603-
}
604-
}
605-
606539
/// <summary>
607540
/// Injects the 2026-07-28 protocol's per-request <c>_meta</c> fields into an outgoing request.
608541
/// Protocol version and client info overwrite any existing values; client capabilities are merged

0 commit comments

Comments
 (0)