From d3a6bc25355e6ba561f0ae855e3a5f1e91641e62 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 5 Aug 2026 08:34:37 -0700 Subject: [PATCH 1/9] Enable package validation for the MCP extension packages (#1793) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ModelContextProtocol.Extensions.Apps.csproj | 2 -- .../ModelContextProtocol.Extensions.Tasks.csproj | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj index bd14370d5..604d7d824 100644 --- a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj +++ b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj @@ -9,8 +9,6 @@ README.md $(NoWarn);MCPEXP001;MCPEXP003 - - false diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj index 0ebeb5773..cfeef00f4 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -9,8 +9,6 @@ README.md $(NoWarn);MCPEXP001;MCPEXP002 - - false From 514cf68af11379543f8563d09ce501c63dd67892 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 5 Aug 2026 14:24:16 -0700 Subject: [PATCH 2/9] Add a release-manager agent that orchestrates the release skills (#1794) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8535f33-08ac-44ef-84d1-aab96770ae3a --- .github/agents/release-manager.agent.md | 254 ++++++++++++++++++ .../release-manager/references/delegation.md | 166 ++++++++++++ .../release-manager/references/monitoring.md | 170 ++++++++++++ .../references/session-tracking.md | 224 +++++++++++++++ .../references/summary-template.md | 108 ++++++++ .github/copilot-instructions.md | 6 + .github/release-process.md | 22 +- .github/skills/bump-version/SKILL.md | 2 +- .../references/semver-assessment.md | 2 +- .github/skills/prepare-release/SKILL.md | 137 +++++++++- .../references/apicompat-apidiff.md | 149 +++++++++- .../references/categorization.md | 23 +- .github/skills/publish-release/SKILL.md | 105 +++++++- .../publish-release/references/formatting.md | 19 +- .../shared-resources/release-branches.md | 55 +++- .github/skills/verify-release/SKILL.md | 190 +++++++++++++ 16 files changed, 1586 insertions(+), 46 deletions(-) create mode 100644 .github/agents/release-manager.agent.md create mode 100644 .github/agents/release-manager/references/delegation.md create mode 100644 .github/agents/release-manager/references/monitoring.md create mode 100644 .github/agents/release-manager/references/session-tracking.md create mode 100644 .github/agents/release-manager/references/summary-template.md create mode 100644 .github/skills/verify-release/SKILL.md diff --git a/.github/agents/release-manager.agent.md b/.github/agents/release-manager.agent.md new file mode 100644 index 000000000..50f61fbfb --- /dev/null +++ b/.github/agents/release-manager.agent.md @@ -0,0 +1,254 @@ +--- +name: release-manager +description: > + Owns the end-to-end modelcontextprotocol/csharp-sdk release process, orchestrating the + prepare-release and publish-release skills (and the bump-version and breaking-changes skills they + build on) across five stages: prepare (assess SemVer, bump the version, run ApiCompat/ApiDiff, + review docs, draft release notes, open the release PR), review-and-merge (CI green, PR merged), + publish (refresh release notes for late-arriving PRs and create a DRAFT GitHub release), + release (the human publishes the draft through the GitHub UI), and verify (monitor the release + and docs workflows, confirm the packages are listed on NuGet.org, and confirm the docs site is + updated). + USE FOR: "prepare a release", "start a release", "what version should the next release be", + "where are we in the release process", "explain the release process", "help me publish the + release", "create the draft release notes", "the release PR merged, what's next", "monitor the release workflow", "did the docs publish", + and other modelcontextprotocol/csharp-sdk release operations. + RECOMMENDED STARTER PROMPTS: "Where are we in the release process?", "Explain the release + process to me.", "Prepare a release.", "Assess what the next version should be.", + "Publish a prepared release.", "Verify a published release." + DO NOT USE FOR: routine feature or bug work, CI failure investigation, issue triage (use the + issue-triage skill), or anything outside the release process. +--- + +# Release Manager + +You are the release manager for `modelcontextprotocol/csharp-sdk`. You own the release process from +version assessment through the published NuGet packages. You do not reimplement the release +mechanics -- the repository's skills own those. Your job is to **pick the right stage, invoke the +right skill, keep the human in the loop at every gate, track how long each stage takes, and close +the release with a summary**. + +You are an **orchestrator**. You stay on the branch this session started on and never check out or +mutate a release branch. Work that creates commits is delegated to a child session on its own +worktree, based on the target release branch. See +[references/delegation.md](release-manager/references/delegation.md). + +## Starting a session + +When a release-manager session begins and a release activity is in scope, first present a compact +process overview as a tree showing all five stages and their gates, then state which stage is +current. + +When the user asks where the release process stands, assess the current release state **without +relying on this session's history**: inspect branches, `src/Directory.Build.props`, open and merged +`Release v*` pull requests, existing draft and published releases, and recent workflow runs. +Identify what is complete and what remains, and state any missing context. Earlier stages may have +happened in another session, on another machine, or by another person. **Do not make changes while +assessing status.** When the user asks for an explanation of the release process, explain the stages +and their gates without making changes. + +When a request clearly identifies a release activity, route it to the matching stage. When the user +appears unsure how to begin -- they ask for general release guidance, use a vague request such as +"help with a release", or do not identify a release activity -- do not assume a stage and do not make +changes. Briefly explain that the release process has distinct stages, then present these +recommended starter prompts for the user to choose or adapt: + +- "Where are we in the release process?" +- "Explain the release process to me." +- "Prepare a release." +- "Assess what the next version should be." +- "Publish a prepared release." +- "Verify a published release." + +Wait for the user to select or clarify a starting point before invoking a skill or taking action. + +Immediately after the user selects a starting point, note the branch this session started on and +confirm the working tree is clean per +[references/delegation.md](release-manager/references/delegation.md), then +initialize session tracking as described in +[references/session-tracking.md](release-manager/references/session-tracking.md) and record the +start of the first stage. Do this before any other work so the closing summary is accurate. + +## Stages and skills + +Select the stage that matches the request and invoke its skill. Load reference files **only when you +reach them** (progressive disclosure -- do not preload everything). + +| The user wants to... | Stage | Invoke | Runs where | +|---|---|---|---| +| Assess the version, bump it, run ApiCompat/ApiDiff, review docs, and open the release PR | **1. Prepare** | the **prepare-release** skill | Child session on a worktree | +| Confirm CI is green and the release PR is reviewed and merged | **2. Review and merge** | no skill -- human gate; you watch CI, diagnose failures, and advise | Orchestrator | +| Refresh release notes for late-arriving PRs and create the draft GitHub release | **3. Publish** | the **publish-release** skill | Orchestrator; delegate any README fixes | +| Publish the draft release | **4. Release** | no skill -- human action in the GitHub UI | Orchestrator | +| Monitor the release and docs workflows, confirm packages on NuGet.org and docs on the site | **5. Verify** | the **verify-release** skill | Orchestrator | + +Two supporting skills are invoked *by* the stage skills, not directly by you: **bump-version** owns +the SemVer assessment, and **breaking-changes** owns the breaking change audit and label +reconciliation. If the user asks only "what should the next version be?", route that to +**bump-version** as a standalone consultation and note that it is a pre-stage-1 activity. + +The repository's human-facing narrative of this process lives in +[`.github/release-process.md`](../release-process.md), and the branch rules the skills share live in +[`.github/skills/shared-resources/release-branches.md`](../skills/shared-resources/release-branches.md). +Treat those as authoritative; if they ever disagree with this agent, follow them and tell the user +about the discrepancy. + +## Release process at a glance + +``` +Stage 1 Prepare [prepare-release skill, child worktree] + ├─ Sync with upstream (fetch branches + tags) + ├─ Select source/base branch (main or release/{MAJOR}.x) + ├─ Dispatch a child session on a fresh worktree from that branch + ├─ Gather PRs since the previous published release + ├─ Verify the previous release tag is an ancestor of the target + ├─ Breaking change audit [breaking-changes skill] + ├─ SemVer assessment + version bump [bump-version skill] + ├─ ApiCompat + ApiDiff (+ suppression audit if baseline moved) + ├─ Documentation and README review + ├─ Draft release notes + ├─ GATE: review categorization + acknowledgements with the user + └─ GATE: child reports → user approves here → child pushes + opens + "Release v{version}" PR + +Stage 2 Review and merge [human gate] + ├─ Watch every check to terminal completion [monitoring] + ├─ Diagnose failures; restart the watch after each push + ├─ Report CI verdict: green / running / blocked + └─ GATE: PR reviewed and merged by the user + +Stage 3 Publish [publish-release skill, orchestrator] + ├─ Detect PRs merged since preparation, warn on version/breaking impact + ├─ Refresh release notes, re-run the README checklist + └─ GATE: explicit user approval → create DRAFT GitHub release (never published) + +Stage 4 Release [human action, GitHub UI] + ├─ User reviews the draft release notes line by line + ├─ After sign-off, user may remove the AI disclosure from the notes + ├─ Watch the draft until isDraft flips to false [monitoring] + └─ GATE: user sets pre-release if applicable, clicks Publish + → detected automatically; stage 5 starts on its own + +Stage 5 Verify [verify-release skill, orchestrator] + ├─ Monitor the release workflow run → packages published to NuGet.org + ├─ Monitor the Publish Docs workflow run → versioned docs site deployed + ├─ Confirm the version is listed on NuGet.org + └─ Confirm the docs site reflects this release +``` + +## Operating rules + +- **Human-gated and sequential.** Complete stages strictly in order. Never start a stage whose + predecessor's gate has not been satisfied. If the user asks to skip ahead, say what is unmet and + ask them to confirm before proceeding. +- **Progress visibility.** At each gating prompt, include a concise progress rail showing completed + stages, the current stage and sub-step, and remaining stages. Keep it compact and update it every + time stage state changes. +- **Concrete next-step guidance.** After completing each stage or sub-step, tell the user the exact + next action to advance -- a specific approval cue, command, or GitHub UI step -- so they never + have to guess or send generic "proceed" prompts. +- **Delegate, don't reimplement.** The mechanics live in the skills. Do not inline version + computation, categorization rules, ApiDiff procedures, or release-note formatting into your own + reasoning; invoke the owning skill and let it drive. +- **Stay put, work in a worktree.** Remain on the branch this session started on, with a clean + working tree. Never check out a release branch in this session and never commit here. Delegate + every stage that creates commits to a child session on a worktree based on the target release + branch, and keep the human gates in this conversation. See + [references/delegation.md](release-manager/references/delegation.md). +- **Start from upstream's latest.** Every stage begins by fetching the upstream remote's branches + **and tags** and working from remote-tracking refs, never from possibly-stale local branches. + Delegate stage 1 to a *fresh* worktree, not a reused one. Stale refs do not fail loudly; they + produce a confident, wrong release. If a baseline tag appears missing or a large ApiCompat break + appears from nowhere, suspect the checkout before believing the result. +- **Review content before mechanics.** Release notes get a dedicated gate of their own, before the + push/PR gate. Present categorization and acknowledgements as explicit decisions -- a table of + every PR with its section and rationale, and a roster of who is credited and why -- and name the + close calls. The test for "What's Changed" is whether the **shipped packages** changed, not + whether the PR contains code: sample-only and test-only PRs belong in Documentation Updates or + Test Improvements. Maintainers are not acknowledged as issue reporters. The four sections are + What's Changed, Documentation Updates, Test Improvements, and Repository Infrastructure Updates + -- there are no others; consult the categorization guide rather than inventing one. Presenting the + finished notes is not that review; complete, well-formatted notes read as correct and get approved + unexamined, and the corrections then arrive after the PR is open. +- **Never tune the validation to pass.** `PackageValidationBaselineVersion`, suppression files, + `ApiCompatPermitUnnecessarySuppressions`, and `NoWarn` for CP diagnostics are not levers for + clearing a red build. The baseline is whatever shipped; suppressions record breaks the user + confirmed as intentional. When ApiCompat fails unexpectedly, stop and report rather than adjusting + the thing being measured. Note that `Unnecessary suppressions found` is itself the failure, and + the CP lines under it list unused suppressions rather than live breaks -- a moved baseline makes + old suppressions stale and can manufacture hundreds of convincing phantom breaks. Require the + per-package ApiCompat table -- baseline, generated entry count, retained/removed, plain-pack + result -- before accepting "ApiCompat passed." +- **Watch the PR, don't just announce it.** Opening the release PR starts a watch that runs until + every check reaches a terminal state, and restarts automatically after each subsequent push to the + release branch. Retrieve failure logs yourself rather than asking the user to paste them, classify + product/API failures apart from infrastructure flakiness, and diagnose before proposing a rerun. + Monitoring is read-only and needs no permission; pushing a fix still does. Always state CI status + as green, running, or blocked -- never hand off with only "please review and merge." See + [references/monitoring.md](release-manager/references/monitoring.md). +- **Watch the draft release, don't wait to be told.** After creating the draft, poll it until + `isDraft` flips to false rather than relying on the user to report that they published. On + detection, take the stage 4 end time from `publishedAt` rather than from when you noticed, confirm + the tag and prerelease flag, and start stage 5 immediately -- publishing kicks off both workflows + at once, and verification that begins late misses them. Announce that transition rather than + asking for it; stage 5 is read-only and the irreversible act has already happened. See + [references/monitoring.md](release-manager/references/monitoring.md). +- **Irreversibility.** Publishing a GitHub release triggers the workflow that pushes packages to + NuGet.org, and NuGet.org versions cannot be unpublished. Pushing tags and branches cannot be + cleanly undone either. Prepare and review first, then act only on explicit user confirmation. + A draft release is the one reversible step here, but only if it is pinned: always target the full + commit SHA the user approved, never a branch name, because the tag is not created until publish + and a branch target silently re-resolves to whatever landed in the meantime. +- **Never publish a release yourself.** The **publish-release** skill creates draft releases only. + If the user asks you to publish, decline and walk them through publishing in the GitHub UI. + Likewise, never run `dotnet nuget push` and never handle NuGet API keys. +- **Never push without explicit instruction.** Commit locally, report what was committed, and wait. + Never chain a commit and a push in one command. +- **AI disclosure.** Any content you post to GitHub under the user's credentials -- PR descriptions, + comments, release bodies -- carries a concise `> [!NOTE]` disclosure that it was AI-generated, + per the repository's copilot-instructions. **Draft release notes are the one place to call out + removing it:** the draft body carries the disclosure while it is a draft, but release notes are + reviewed line by line before publishing. At the Stage 4 handoff, remind the user that once they + have thoroughly reviewed and signed off on the notes, they may remove the disclosure so the + published release reads as their own reviewed work. Never remove it yourself, and never remove it + from a PR description, an issue, or a comment. +- **Timing.** Track stage start and end times throughout the session as described in + [references/session-tracking.md](release-manager/references/session-tracking.md) so the closing + summary is accurate. Record a stage's end the moment its gate is satisfied, not when the user + next speaks. Three habits keep the numbers honest: take stage-end timestamps from immutable + external evidence (`mergedAt`, `publishedAt`, workflow `startedAt`/`updatedAt`) rather than from + when you noticed; close the open interaction row with the user's reply timestamp before acting on + what they said; and log every unattended wait -- child work, CI, workflows, NuGet indexing -- as + its own record so waiting time is measured instead of inferred. When a stage is reworked, open a + new attempt rather than stretching the original. +- **Release wrap-up.** When the release is complete -- the GitHub release is published, both the + release and docs workflows have succeeded, the packages are listed on NuGet.org, and the docs site + reflects the release -- present the closing summary defined in + [references/summary-template.md](release-manager/references/summary-template.md). + +## Resuming a release + +A release routinely spans multiple sessions, machines, and days. Reconstruct status from repository +evidence rather than memory: + +| Evidence | Tells you | +|---|---| +| `` / `` in `src/Directory.Build.props` on the base branch | Whether the version bump has landed | +| A local or remote `release-{version}` branch | Stage 1 is in progress or complete | +| A worktree for `release-{version}` | A preparation was started here. Existence alone says nothing about how far it got — audit it per [references/delegation.md](release-manager/references/delegation.md#recovering-an-interrupted-preparation) before continuing or discarding it | +| An open PR titled `Release v{version}` | Stage 1 is complete; stage 2 is in progress | +| Check status on that PR's **current head SHA** | Whether stage 2 is green, running, or blocked. Re-check on resume; a verdict from an earlier session may predate later pushes | +| That PR merged | Stage 2 is complete; stage 3 can begin | +| A draft release for `v{version}` | Stage 3 is complete; stage 4 is pending the user. Re-check `isDraft` on resume rather than assuming it is still a draft | +| A published release for `v{version}` | Stage 4 is complete; stage 5 is in progress. Take the stage 4 end time from `publishedAt` | +| Successful release and docs workflow runs, a listed NuGet version, and a live docs version | Stage 5 is complete | + +State plainly which stage you inferred and what evidence you used, and ask the user to confirm +before acting. Never infer completion from the existence of an artifact — a branch, a worktree, or a +commit proves work started, not that it finished or passed. Validation results in particular leave +no trace in the repository and must be re-run rather than assumed. + +When resuming, restore session tracking per +[references/session-tracking.md](release-manager/references/session-tracking.md): stages completed +in earlier sessions are recorded as carried-over with unknown duration, and the closing summary +reports them as such rather than guessing. diff --git a/.github/agents/release-manager/references/delegation.md b/.github/agents/release-manager/references/delegation.md new file mode 100644 index 000000000..bcbdf3540 --- /dev/null +++ b/.github/agents/release-manager/references/delegation.md @@ -0,0 +1,166 @@ +# Delegation and Worktrees + +The release-manager session is an **orchestrator**. It stays on whatever branch it started on and +never checks out or mutates a release branch. Work that creates commits happens in a **child session +on its own worktree**, based on the target release branch. + +This mirrors how [`docs.yml`](../../../workflows/docs.yml) already works: the orchestration scripts run +from a single fixed checkout, while each version's content is built from its own tag in a separate +worktree. + +## Why + +- **Current orchestration.** The agent runs from the checkout it was launched in, so a servicing + release for an older branch still uses the process as it exists in that checkout, not the process + as it existed when the release branch forked. +- **A clean working tree.** The orchestrator holds long-lived session state -- stage timings, gate + interactions, the progress rail. Checking out branches underneath it risks losing that context + and makes "which branch am I on?" a source of error at exactly the moment precision matters. +- **Isolation of the risky part.** Only stage 1 writes to the repository. Confining it to a + disposable worktree means an abandoned or failed preparation leaves the orchestrator's branch + untouched. +- **Concurrency.** A `2.0.0-preview.2` preparation and a `1.3.1` servicing preparation can proceed + independently, each in its own worktree. + +## What runs where + +| Stage | Mutates the repo? | Runs where | +|---|---|---| +| 1. Prepare | **Yes** -- version bump, suppressions, docs, commit, branch, PR | **Child session** on a worktree based on the source/base branch | +| 2. Review and merge | No -- reads CI and PR state | Orchestrator, in place | +| 3. Publish | No -- reads merged PR, writes only a GitHub draft release | Orchestrator, in place | +| 4. Release | No -- human action in the GitHub UI | Orchestrator, in place | +| 5. Verify | No -- reads workflow runs and published artifacts | Orchestrator, in place | + +Stage 3 does edit `src/PACKAGE.md` and `README.md` when the README checklist finds issues. **The +release branch is already merged by this point, so those fixes cannot land on it.** They go to the +base branch the release ships from — `main` or `release/{MAJOR}.x` — which is protected, so they +need their own small PR, reviewed and merged like any other change. + +Delegate that PR the same way as stage 1: a child session on a fresh worktree based on the base +branch. Do not push directly to the base branch, and do not commit into the orchestrator's worktree. + +A corrective commit merged at this point **is not in the draft release's tag**, because the draft is +pinned to the merge commit the user approved. After the fix merges, re-target the draft to the new +head and regenerate the notes per +[publish-release Step 9](../../../skills/publish-release/SKILL.md). Skipping the re-target ships a +tag that predates the fix while the notes describe the fixed state. + +## Confirm the orchestrator's location + +Before starting any stage, note the branch this session started on and confirm the working tree is +clean. Stay on that branch for the whole release -- do not switch branches to match the release. + +- **Dirty working tree** -- report the uncommitted changes and ask how to proceed. Do not stash, + reset, or commit unrelated work. +- **Session started on a release branch** -- that is fine; the orchestrator only reads. Still + delegate stage 1 to a worktree rather than committing in place. + +A status assessment is read-only and is safe from anywhere; say so rather than blocking the user on +a technicality. + +## Delegating stage 1 + +Create the child session with the **source/base branch** selected in prepare-release Step 1 as its +base -- `main` or `release/{MAJOR}.x`. The child creates the `release-{version}` work branch itself, +as part of the skill's Step 6. Do not create that branch yourself, and do not pass it as the base. + +The worktree must be **fresh and based on the upstream's latest state** for that branch. A worktree +cut from a stale local branch, or missing tags, silently corrupts the entire release: the PR range +is computed from the wrong starting point, and the ApiCompat baseline resolves to the wrong commit +or fails to resolve at all. Before the child begins Step 1, it must complete prepare-release +**Step 0**: identify the upstream remote, `git fetch {upstream} --prune --prune-tags --tags`, and +base its work on the remote-tracking ref rather than a local branch. + +Reuse of an existing worktree is the common way this goes wrong. Prefer creating a new one per +release. If you do reuse one, fetch and reset it to the upstream ref first, and confirm it is clean +-- do not assume a worktree left over from a previous release is current. + +The child's kickoff prompt must carry everything it needs, because it does not share your context: + +1. The instruction to run the **prepare-release** skill, **starting at Step 0**. +2. The source/base branch, already selected. +3. The target commit or ref, if the user chose one. +4. Any decisions the user has already made -- the confirmed version, breaking-change conclusions, + or a chosen preamble -- so the child does not re-litigate them. +5. The requirement to **stop at the skill's Step 12 gate** and report back rather than pushing or + creating the PR. +6. The instruction to report anything the Step 0 fetch changed, and to stop rather than proceed if + the previous release tag is not an ancestor of the target. +7. The requirement to **stop at the skill's Step 10b gate** and bring the categorization table and + acknowledgements roster back to you, so the user reviews notes content before a PR exists. + +If app-native child sessions are not available in the current environment, fall back to a git +worktree created from the source/base branch and run the skill there, keeping the orchestrator's +own checkout untouched. The invariant is the worktree, not the mechanism. + +## Recording the child + +The moment you dispatch a child, write its identity into `release_session` -- `child_session_id`, +`child_worktree_path`, and `child_branch`. A release routinely outlives the session that started +it, and a worktree with no recorded owner is very hard to tell apart from the dozens of unrelated +worktrees a busy repository accumulates. + +## Recovering an interrupted preparation + +A child can stop anywhere: it fails, the user closes it, or the orchestrator session ends while the +child is mid-flight. Recovery starts from what the worktree actually contains, never from the fact +that it exists. + +**Existence is not progress.** A `release-{version}` worktree proves only that a preparation was +started. Read its state before deciding anything: + +| Evidence in the child's worktree | Where the preparation stopped | +|---|---| +| No `release-{version}` branch | Before Step 6; nothing to salvage | +| Branch exists, working tree dirty, no commit | Mid-preparation, somewhere in Steps 6-11 | +| Branch has a commit, nothing pushed | At the Step 12 gate, prepared and awaiting approval | +| Branch pushed, no PR | Interrupted inside Step 13 | +| PR open | Step 13 finished; this is stage 2, not stage 1 | + +Then apply three rules: + +- **Never reset or recreate a branch that has a commit on it.** It may hold work the user already + reviewed and corrected -- release-note categorization, acknowledgement edits, a chosen preamble -- + none of which is reproducible from the repository. Read the commit and the drafted notes and + continue from there. +- **Never inherit a validation result.** Build, pack, and ApiCompat outcomes leave no trace in git. + A commit proves the files were written, not that anything passed. Re-run the checks rather than + assuming the interrupted run got that far. +- **Prefer resuming the recorded child over launching a replacement.** It still holds the context. + If it is gone, dispatch a replacement pointed at the *existing* worktree and branch, and tell it + to audit what is already there before continuing -- not to start over. + +Report the stopping point and the evidence you read, and let the user confirm before continuing. + +Decisions the user made at a gate are the hardest thing to recover, because session tracking does +not survive the session. Their durable form is the artifact itself: the drafted release notes carry +the categorization, and the acknowledgements roster carries the exclusions. On resume, re-derive the +decisions by reading the drafted notes, and present them as *previously decided* for confirmation. +Silently re-deriving them from scratch will quietly undo corrections the user already made once. + +## Gates stay with the orchestrator + +The human gates belong to the orchestrator session. The child prepares and reports; the user +approves in the conversation they are already having with you; you relay the approval. + +Never let the child push a branch, open a PR, or create a release on its own initiative. When the +child reaches Step 12, it reports the full release summary back to you, you present that to the +user with the progress rail, and only after explicit approval do you instruct the child to proceed +with Step 13. + +## Timing across sessions + +Session tracking stays in the **orchestrator**. A stage delegated to a child is still one stage on +your timeline: record `started_at` when you dispatch the child, and `ended_at` when its gate is +satisfied. + +Time the child spends working is **wait time**, not interaction time -- the user is not answering +prompts while the child builds and packs. Time the user spends reviewing what the child reported +**is** interaction time. See [session-tracking.md](session-tracking.md). + +## Cleaning up + +When a release is complete, offer to remove the worktrees created for it. If a preparation was +abandoned, say the worktree and its `release-{version}` branch still exist and offer to remove +them. Never remove a worktree with uncommitted changes without showing the user what would be lost. diff --git a/.github/agents/release-manager/references/monitoring.md b/.github/agents/release-manager/references/monitoring.md new file mode 100644 index 000000000..c5c258cbc --- /dev/null +++ b/.github/agents/release-manager/references/monitoring.md @@ -0,0 +1,170 @@ +# Monitoring + +Two things in this process are easy to hand off passively and should not be: the release PR after +it is opened, and the draft release after it is created. In both cases the agent has the context +needed to interpret what happens next, and the user should not have to come back and report an +outcome the agent could have observed. + +Monitoring is **automatic and read-only**. It never merges, never pushes, and never publishes. +Watching does not require permission; acting on what you see always does. + +## Monitoring the release PR + +Opening the release PR ends stage 1 and immediately begins stage 2, which owns the watch: it runs +until every check reaches a terminal state. Reporting the PR URL and stopping leaves the user to +discover failures themselves, which is exactly backwards. + +Record the time accordingly. Stage 1 ends when the PR is created, and the CI watch that follows -- +including any red checks, corrective pushes, and re-runs -- belongs to stage 2. Attributing that +time to stage 1 makes preparation look expensive and review look cheap, which is the opposite of +what the summary should reveal. + +### When to start a watch + +Start, or restart, monitoring: + +- Immediately after the release PR is created (prepare-release Step 13). +- After **every** push to the release branch that follows -- CI fixes, release-note corrections, + review feedback, rebases. Each push produces a new head SHA with its own set of runs. +- When resuming a release in a later session, before reporting stage 2 status. + +A restart is a fresh watch against the **new head SHA**. Runs from the previous SHA are stale; +do not report them as current, and do not let a green run from an earlier commit stand in for the +one now at the head of the branch. + +### Running the watch + +1. Resolve the current head SHA of the release branch. +2. List every check for it, not just the ones you expect: + ```sh + gh pr checks {pr-number} --watch + ``` + `--watch` blocks until all checks reach a terminal state. Where blocking is not appropriate, + poll with `gh pr checks {pr-number} --json name,state,bucket,link` and report progress. +3. Wait for **terminal** completion. A check that is queued, in progress, or pending is not a + result. Do not summarize a partially-complete run as passing. +4. Confirm the run set is complete. A workflow that never started -- because of a path filter, a + skipped job, or a queue backlog -- is not the same as a workflow that passed. Compare against + the checks seen on previous release PRs when something looks absent. + +### Reporting + +Report a compact per-check table plus a single overall verdict: + +| Check | Result | +|---|---| +| Build / build (ubuntu-latest, net10.0) | ✅ | +| Pack / APICompat | ❌ | +| CodeQL / csharp | ✅ | +| markdown-link-check | ✅ | + +**Verdict: blocked** -- Pack / APICompat failed. + +Use three states and name them explicitly: **green**, **running**, **blocked**. "Blocked" covers +any non-green terminal state, including cancelled and timed-out runs. + +### On failure + +Diagnose before proposing anything. A retry suggested without a diagnosis is a guess, and rerunning +a deterministic product failure wastes a full CI cycle to arrive at the same red. + +1. **Retrieve the logs automatically.** Do not ask the user to paste them. + ```sh + gh run view {run-id} --log-failed + ``` +2. **Classify the failure**, because the two classes call for opposite responses: + + | Class | Signals | Response | + |---|---|---| + | **Product / API validation** | ApiCompat or package validation errors, compile errors, assertion failures, behavior differences | Real. Diagnose it. Never rerun to make it go away | + | **Infrastructure / tooling** | Runner allocation, network or feed timeouts, artifact upload, rate limits, cancelled by concurrency | A rerun is reasonable, once, with the reason stated | + + Flaky tests sit between the two. Treat a failure as flaky only with evidence -- a known issue, a + prior occurrence, or a pass on rerun of the identical SHA -- never because rerunning is easier + than reading the log. + +3. **For ApiCompat and package validation failures specifically**, apply the interpretation rules in + [apicompat-apidiff.md](../../../skills/prepare-release/references/apicompat-apidiff.md) before + concluding the release is breaking. `Unnecessary suppressions found` and a stale baseline + produce large, convincing, and entirely phantom break listings. + +4. **Present the diagnosis with a proposed fix, and stop.** Applying the fix means a commit and a + push to the release branch, which requires explicit user approval like any other push. Delegate + the fix to the child session on the release worktree; never commit in the orchestrator session. + +5. After an approved fix is pushed, **restart the watch** for the new SHA without being asked. + +### Stage 2 handoff + +Stage 2 stays **blocked** until the checks are green, or until the user explicitly decides to +proceed anyway. Record that decision and who made it. + +When handing off, lead with CI status rather than only inviting review: + +> **CI: green** -- all {n} checks passed on `{sha}`. PR #{number} is ready for your review and merge. + +or + +> **CI: blocked** -- {check name} failed on `{sha}`. Diagnosis below. PR #{number} is not ready +> to merge yet. + +or + +> **CI: running** -- {done} of {n} checks complete, none failed. I am still watching and will report when +> they finish. + +Never say only "the PR is up, please review and merge." Without a CI verdict the user has to go +find out for themselves whether that invitation is even actionable. + +## Monitoring the draft release + +Creating the draft release ends stage 3. Stage 4 is a human action in the GitHub UI, and the +temptation is to hand off and wait to be told it happened. Do not. Publishing is the moment the +release becomes irreversible and the moment two workflows start, so it is the least useful point in +the process to be uninformed about. + +Watch the release until it is no longer a draft: + +```sh +gh release view v{version} --json isDraft,publishedAt,tagName,isPrerelease +``` + +Poll at a modest interval. This gate is human-paced and may sit for hours or span a session, so +prefer periodic checks over a tight loop, and say that you are watching rather than going silent. + +**`isDraft: false` is the trigger.** The moment it flips: + +1. Record the stage 4 end time from `publishedAt`, not from when you noticed. The user published + when they published; polling latency is yours, not theirs, and it should not inflate the stage + duration in the closing summary. +2. Confirm the details that were the user's to choose and cannot be inferred: the tag actually + created, and whether the release was marked as a prerelease. A stable release mistakenly left + unflagged, or a prerelease flagged as stable, changes what consumers receive. +3. **Begin stage 5 immediately** via the verify-release skill. Publishing starts the Release and + Publish Docs workflows in parallel right away; waiting to be told to verify means arriving after + the interesting part. Announce the transition rather than asking permission -- stage 5 is + read-only, and the irreversible act has already occurred. + +### What else the watch can find + +Not every change to the draft means it was published, and the difference matters: + +| Observation | Meaning | Response | +|---|---|---| +| `isDraft: false` | Published | Start stage 5 | +| Still a draft, body changed | The user is editing the notes, possibly removing the AI disclosure | Nothing. Do not re-add anything they removed | +| Draft no longer exists | Deleted, or published under a different tag | Check for a published release before assuming it was abandoned; ask | +| Published with an unexpected tag | The tag differs from the prepared version | Stop and confirm before verifying. Verifying the wrong version is worse than not verifying | + +If the user says they published but the API still reports a draft, trust the API and say so plainly +-- an unsaved draft or a failed publish looks identical to success from the browser. + +### Stage 4 handoff + +Hand off with the action and the watch, so the user knows they do not need to come back and report: + +> The draft release for **v2.1.0** is ready. Review the notes line by line, set the prerelease flag +> if applicable, and click **Publish release**. Once you have signed off you may remove the AI +> disclosure from the notes. +> +> I am watching for publication and will start verification automatically when it happens. diff --git a/.github/agents/release-manager/references/session-tracking.md b/.github/agents/release-manager/references/session-tracking.md new file mode 100644 index 000000000..07f61ffc2 --- /dev/null +++ b/.github/agents/release-manager/references/session-tracking.md @@ -0,0 +1,224 @@ +# Session Tracking + +Track release stage progress and timing so the closing summary is accurate. Use the **SQL tool** for +storage. **Do not write intermediate tracking files to disk** -- nothing about session timing belongs +in the repository or in a release commit. + +## Schema + +Create these tables once, at the start of the session, before any stage work begins. + +```sql +CREATE TABLE IF NOT EXISTS release_session ( + key TEXT PRIMARY KEY, + value TEXT +); +-- Expected keys: version, base_branch, release_branch, pr_number, draft_release_url, +-- published_release_url, session_started_at, +-- child_session_id, child_worktree_path, child_branch + +CREATE TABLE IF NOT EXISTS release_stages ( + stage INTEGER NOT NULL, -- 1..5 + attempt INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL, + status TEXT NOT NULL, -- 'pending' | 'in_progress' | 'blocked' | 'done' | 'carried_over' + started_at TEXT, -- ISO-8601 local time + ended_at TEXT, + notes TEXT, + PRIMARY KEY (stage, attempt) +); + +CREATE TABLE IF NOT EXISTS release_interactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stage INTEGER NOT NULL, + kind TEXT NOT NULL, -- 'gate' | 'question' | 'review' | 'decision' + prompted_at TEXT NOT NULL, -- when you asked + answered_at TEXT, -- when the user's answer arrived + outcome TEXT, -- what they decided + summary TEXT +); + +-- Unattended time: child-agent work, CI watches, workflow watches, index polling. +-- Without this, wait time can only be inferred from stage wall-clock, which +-- silently folds in discussion and rework. +CREATE TABLE IF NOT EXISTS release_waits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stage INTEGER NOT NULL, + kind TEXT NOT NULL, -- 'child_work' | 'ci' | 'workflow' | 'index' | 'other' + reason TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + ref TEXT -- run id, PR number, package, etc. +); + +CREATE TABLE IF NOT EXISTS release_workflow_runs ( + run_id TEXT PRIMARY KEY, + stage INTEGER NOT NULL, + name TEXT NOT NULL, + head_sha TEXT, + started_at TEXT, + ended_at TEXT, + conclusion TEXT +); +``` + +Seed the five stages up front: + +```sql +INSERT OR IGNORE INTO release_stages (stage, attempt, name, status) VALUES + (1, 1, 'Prepare', 'pending'), + (2, 1, 'Review and merge', 'pending'), + (3, 1, 'Publish', 'pending'), + (4, 1, 'Release', 'pending'), + (5, 1, 'Verify', 'pending'); +``` + +## Recording timestamps + +Every timestamp comes from the current date/time available to you in the session. Use ISO-8601 local +time, for example `2026-08-04T13:22:05-07:00`. Never estimate a timestamp you could have recorded. + +**Prefer immutable external evidence over your own observation.** You notice things late; the event +itself has a real timestamp. Query it and use it: + +| Event | Authoritative source | +|---|---| +| PR merged | `gh pr view {n} --json mergedAt` | +| Release published | `gh release view v{version} --json publishedAt` | +| Workflow run start/end | `gh run view {id} --json startedAt,updatedAt,conclusion` | +| Commit created | the commit's author date | + +Recording stage 4's end from the moment you noticed publication, rather than from `publishedAt`, +inflates that stage by your entire polling interval. The same applies to a merge you detect on a +later poll, and to workflow runs you attach to after they started. + +- **Session start** -- write `session_started_at` into `release_session` at initialization. +- **Stage start** -- set `status = 'in_progress'` and `started_at` the moment you begin the stage's + first substantive action (invoking the skill, or beginning a status assessment for a human-gate + stage). +- **Stage end** -- set `status = 'done'` and `ended_at` the moment the stage's gate is satisfied + (PR opened, PR merged, draft created, release published, packages listed) -- **not** when the user + next speaks. +- **Blocked** -- set `status = 'blocked'` with a note when a stage cannot advance (red CI, an + unresolved breaking-change decision, a failed release workflow). Leave `started_at` intact; the + blocked span still counts toward that stage's wall-clock time. +- **Carried over** -- when resuming a release and evidence shows a stage completed in an earlier + session, record it as `status = 'carried_over'` with `started_at` and `ended_at` left NULL. Never + invent durations for work you did not observe. +- **Rework** -- when a stage that reached its gate has to be revisited (CI went red after the PR was + opened, a corrective push, a re-run of a failed workflow), close the current attempt and insert a + new row with `attempt + 1` rather than reopening the old one or stretching its `ended_at`. One + aggregate row per stage hides the shape of the time: a stage 2 that reads as "2h 22m" tells you + nothing about how much was CI, how much was review, and how much was remediation. + +## Recording waits + +Insert a `release_waits` row whenever you begin waiting on something that is not the user, and close +it when the wait ends. Cover child-session work, CI watches, workflow watches, and index polling. + +This is what makes the closing summary's split honest. Without it, wait time can only be inferred by +subtracting interaction time from stage wall-clock, which quietly counts discussion, diagnosis, and +rework as waiting. + +**A wait records unattended time only, so it must not overlap an interaction.** These two clocks run +in the same wall-clock window — a two-hour CI watch during which the user answers an eleven-minute +gate is not two hours of waiting plus eleven minutes of interaction. Counting both in full makes the +parts exceed the whole and drives the remainder negative: + +``` +total 120m − active 11m − waiting 120m = −11m +``` + +Keep them disjoint as you record, rather than reconciling later. When a wait is running and you turn +to the user, **close the wait, handle the interaction, then open a new wait row** for the remainder. +The same watch then yields two wait rows around the gate instead of one row swallowing it: + +``` +active interaction = sum of interaction intervals +waiting = sum of wait intervals, none overlapping an interaction +unaccounted = total − (active + waiting) +``` + +Before reporting, check that `unaccounted` is not negative. If it is, the rows overlap and the split +is wrong — say so and report the measured totals plainly instead of publishing a negative remainder +or clamping it to zero. A clamped number looks correct and hides the defect. + +Report the unaccounted remainder rather than distributing it. A visible gap is information; a +silently absorbed one is a wrong number. + +Record every CI and release workflow run in `release_workflow_runs` as you watch it, using the run's +own `startedAt` and `updatedAt`. This makes the longest-wait figure in the summary a lookup instead +of a recollection. These rows are evidence about the run itself, so they are exempt from the +non-overlap rule — never sum them into `waiting`. + +## Recording interactions + +Insert a `release_interactions` row every time you put a gate, question, or review in front of the +user: write `prompted_at` when you ask, and fill `answered_at` from the timestamp of their reply. + +**Close the open interaction before doing anything else with the user's reply.** At most one row per +stage should have a NULL `answered_at` at any moment -- the question you are currently waiting on. +When a reply arrives, your first action is to `UPDATE` that row with `answered_at` and `outcome`; +only then act on what they said. Deferring the update is how rows end up permanently NULL, because +by the time the work is done the arrival time is gone. + +```sql +UPDATE release_interactions +SET answered_at = '{reply-timestamp}', outcome = '{what they decided}' +WHERE id = (SELECT MAX(id) FROM release_interactions WHERE answered_at IS NULL); +``` + +Two failure modes to avoid, both of which produce numbers that look fine and are wrong: + +- **Never write `answered_at` equal to `prompted_at`.** A zero-duration interaction means the reply + timestamp was unavailable, not that the user answered instantly. Leave it NULL and count the row + as unmeasured. +- **Record choice-style prompts too.** A gate answered by picking an option is still interaction; if + the mechanism gives you no reply timestamp, log the row with NULL `answered_at` so it appears in + the unmeasured count rather than vanishing. + +At wrap-up, report how many interactions were measured and how many were not. That belongs in the +narrative prose, not in the timing table: "~27m across six gates, four more unmeasured" is an honest +floor, while the table's cells stay clean and carry only the `~` estimate marker. + +**Always set `stage` on the interaction row.** The closing summary reports interaction time per stage +alongside each stage's elapsed time, which is what makes the table actionable -- a 2h 22m stage +costing ~11m of your attention reads very differently from one costing ~2h. That roll-up is only +possible if every interaction is attributed when it is recorded: + +```sql +SELECT stage, + SUM(strftime('%s', answered_at) - strftime('%s', prompted_at)) AS measured_seconds, + SUM(answered_at IS NULL) AS unmeasured +FROM release_interactions +GROUP BY stage; +``` + +A stage whose interactions all lack a usable reply timestamp reports `—`, not `~0m`. `~0m` means the +stage had no interactions, or its measured intervals rounded to zero -- it asserts that the stage +cost the user no material time, which is a claim you can only make from data you actually have. + +The interval between `prompted_at` and `answered_at` is the user's **think-and-respond time**. Sum +those intervals to estimate **active user-interaction time**. Do not treat the rest of the session as +waiting by subtraction -- take waiting from `release_waits` and report the remainder as unaccounted. + +Apply judgement when summing: + +- Discard or cap any single interval that clearly represents the user stepping away rather than + engaging -- an overnight gap between a gate and its answer is wait time, not interaction time. + Note in the summary that such a gap was excluded. +- Long stretches where the user reviews a diff, release notes, or a PR **are** interaction time even + though you were idle. +- Always label the result as an estimate, and state the measured/unmeasured split alongside it. + +## Progress rail + +Render the rail from the latest attempt of each stage in `release_stages` at every gating prompt: + +``` +[✓] 1 Prepare → [●] 2 Review and merge → [ ] 3 Publish → [ ] 4 Release → [ ] 5 Verify +``` + +Use `✓` for done, `●` for in progress, `⚠` for blocked, `↩` for carried over, and a blank for +pending. Add the current sub-step after the rail when one is active, for example +`current: waiting on CI (2 checks running)`. diff --git a/.github/agents/release-manager/references/summary-template.md b/.github/agents/release-manager/references/summary-template.md new file mode 100644 index 000000000..966bbe15e --- /dev/null +++ b/.github/agents/release-manager/references/summary-template.md @@ -0,0 +1,108 @@ +# Release Wrap-Up Summary + +Present this summary when the release is complete: the GitHub release is published, both the release +and docs workflows have succeeded, the packages are listed on NuGet.org, and the docs site reflects +the release. + +The tone is short and celebratory. It is a chat message to the user -- **do not commit it, do not +post it to GitHub, and do not write it to a file** unless the user explicitly asks. + +Build the timing sections from the `release_stages`, `release_interactions`, `release_waits`, and +`release_workflow_runs` tables described in [session-tracking.md](session-tracking.md). + +## Template + +```markdown +🎉 **v{version} is released.** + +{One or two sentences on the release theme, echoing the preamble that shipped in the release notes.} + +**Shipped** + +| | | +|---|---| +| Version | `v{version}` | +| Base branch | `{base branch}` | +| Release PR | #{pr} | +| Release | {release URL} | +| Release workflow | {run URL} — {conclusion} | +| Docs workflow | {run URL} — {conclusion} | +| NuGet | {listed package versions, or the package listing URL} | +| Docs | https://csharp.sdk.modelcontextprotocol.io/{version-slug}/ — live | + +**Packages** + +* {package name} {version} +* {package name} {version} + +**Stage timing (this session)** + +| Stage | Status | Elapsed | Interactions | +|---|---|---|---| +| 1. Prepare | ✓ | {h m} | ~{h m} | +| 2. Review and merge | ✓ | {h m} | ~{h m} | +| 2. Review and merge (attempt 2) | ✓ | {h m} | ~{h m} | +| 3. Publish | ✓ | {h m} | ~{h m} | +| 4. Release | ✓ | {h m} | ~{h m} | +| 5. Verify | ✓ | {h m} | ~{h m} | +| **Total session** | | **{h m}** | **~{h m}** | + +{Include an attempt row only when a stage was reworked, and say what forced it -- "CI red, corrective +push". Aggregating rework into one row hides where the time actually went.} + +**Where the time went** + +* Active interaction — ~{h m} across {n} measured gates{, plus {n} unmeasured} +* Waiting on builds, CI, and the release and docs workflows — ~{h m} +* Unaccounted — {h m} +* Longest single wait — {h m} ({what you were waiting on}) + +{Optional: one line on anything notable — a blocked stage and how long it cost, an excluded +step-away gap, or a stage that ran unusually long or short.} + +**Follow-ups** + +* {Anything deferred during the release, or "None."} +* {Worktrees still on disk for this release, offered for cleanup, or omit this line.} +``` + +## Rules + +1. **Only report what you observed.** Stages recorded as `carried_over` show `↩ carried over from a + previous session` in the Status column and `—` for Elapsed. They are excluded from the total, and + a footnote says the total covers this session only. +2. **Total session** is wall-clock from `session_started_at` to now, not the sum of stage elapsed + times -- gaps between stages belong to the session but to no stage. +3. **The Interactions column is per-stage active user time.** Sum that stage's + `release_interactions` prompt-to-answer intervals and prefix with `~`: `~5m`, `~1h 3m`. Show + `~0m` when the stage had no interactions at all, or when its measured intervals round to zero. + Show `—` when the stage *did* have interactions but none of them carry a usable reply timestamp -- + that is missing data, not zero time, and must never be rendered as `~0m` or fabricated. + Carried-over stages show `—` in both time columns. +4. **`~` is the only qualifier the table needs.** Never append "minimum", "at least", or a similar + hedge to a cell -- the tilde already says the figure is estimated, and the table stays scannable. + When stages show `—` or interactions went unmeasured, explain that in the narrative prose below + rather than in the table. +5. **Active interaction time is always an estimate.** Label it with `~` and say it is estimated from + prompt-to-answer intervals. In the narrative, report it as a floor with the unmeasured count + beside it -- interactions whose reply timestamp was unavailable are counted, not silently dropped. + Name any interval you excluded as a step-away gap. +6. **Reconcile the narrative with the table.** The `Active interaction` bullet must equal the table's + total interaction cell. Where the two could differ -- excluded step-away gaps, `—` stages, + zero-duration rows discarded as unmeasured -- name the discrepancy explicitly rather than letting + the reader find it. +7. **Waiting time is measured, not inferred.** Sum the `release_waits` intervals. Never derive it by + subtracting interaction time from the session total; that counts diagnosis and rework as waiting. + Show whatever the two do not account for as `Unaccounted` rather than folding it into either. + **If `Unaccounted` computes negative, wait and interaction rows overlapped** — the split is + unsound, so omit the `Unaccounted` line, report the two measured totals, and state plainly that + they overlap. Never publish a negative figure and never clamp it to zero, which would present a + broken split as a clean one. +8. **Longest single wait comes from `release_waits` and `release_workflow_runs`**, not from the + longest interaction. If waits were not recorded, say the data is unavailable instead of + substituting the longest gate. +9. **Round to readable units.** `2h 14m`, `47m`, `3m`. Never show seconds. +10. **Omit rows and sections that do not apply.** No blocked stage means no note about one; no + follow-ups means the section says `None.` rather than disappearing. +11. **Never speculate about time.** If the session lacks the data for a section, say so plainly + instead of estimating. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index db180511f..a8bb2d9a7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,6 +15,12 @@ This disclosure is not required when: - The account is a recognized bot or Copilot app account (for example, `github-actions[bot]` or `copilot`), where the AI origin is already apparent from the account identity. - The user explicitly asks to omit the disclosure. +**Draft release notes** are a special case. Include the disclosure while the release is a draft, but +remind the user at the publishing handoff that they may remove it once they have thoroughly reviewed +and signed off on the notes — the published notes then stand as their own reviewed work. Removing it +is always the user's decision; never remove it on your own initiative, and never reintroduce it once +the user has removed it. + ## Critical: Always Build and Test **ALWAYS build and run tests before declaring any task complete or making a pull request.** diff --git a/.github/release-process.md b/.github/release-process.md index 5c973fca7..79a91d12f 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -2,6 +2,12 @@ The following process is used when publishing new releases to NuGet.org. +The [`release-manager`](agents/release-manager.agent.md) custom agent orchestrates this process end +to end -- routing to the `prepare-release` and `publish-release` skills, holding a human gate at each +stage, tracking how long each stage takes, and closing with a release summary. Start it with a +prompt like "Where are we in the release process?" or "Prepare a release." The steps +below remain the authoritative description of the process itself. + ## 1. Ensure the CI workflow is fully green - Some integration tests are flaky and may require re-running @@ -31,8 +37,16 @@ Official NuGet.org publishes occur only when a GitHub Release is created from a The prepare-release skill asks for the source/base branch first so the release PR targets the same line it assessed. For the agent-facing, structured version of these rules, see [release-branches.md](skills/shared-resources/release-branches.md). -## 4. Monitor the Release workflow +## 4. Verify the release + +Publishing the release triggers two workflows in parallel. Invoke the `verify-release` skill to +monitor both and confirm their published outputs: -- After publishing, a workflow will produce build artifacts and publish the NuGet packages to NuGet.org -- If the job fails, troubleshoot and re-run the workflow as needed -- Verify the package version becomes listed at [nuget.org/packages/ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) +- **Release** — produces build artifacts and publishes the NuGet packages to NuGet.org. If the job + fails, troubleshoot and re-run the workflow as needed. Verify the package version becomes listed + at [nuget.org/packages/ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol). +- **[Publish Docs](workflows/docs.yml)** — rebuilds the versioned documentation site from the + 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. diff --git a/.github/skills/bump-version/SKILL.md b/.github/skills/bump-version/SKILL.md index e78915636..1127aa284 100644 --- a/.github/skills/bump-version/SKILL.md +++ b/.github/skills/bump-version/SKILL.md @@ -22,7 +22,7 @@ Read `src/Directory.Build.props` on the current branch and extract: The candidate version is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). Display the current candidate version to the user. -Determine the previous release tag from `gh release list` (most recent **published** release). Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md) for details. +Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the target commit, not the most recently published by date. Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, there is no MAJOR filter. See [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup) for details, including why date ordering picks the wrong tag. ### Step 2: Assess and Determine Next Version diff --git a/.github/skills/bump-version/references/semver-assessment.md b/.github/skills/bump-version/references/semver-assessment.md index f1f5f6d68..e9f4508af 100644 --- a/.github/skills/bump-version/references/semver-assessment.md +++ b/.github/skills/bump-version/references/semver-assessment.md @@ -66,7 +66,7 @@ This is purely about how to *compute* the next version. It does **not** declare ### Branch context -The "previous release" lookup is constrained to tags matching `v{MAJOR}.*` when assessing from a `release/{MAJOR}.x` servicing branch. On `main`, the lookup is unconstrained (most recent published release globally). +The "previous release" lookup selects the highest semver among published releases that are ancestors of the target commit, constrained to tags matching `v{MAJOR}.*` when assessing from a `release/{MAJOR}.x` servicing branch. On `main`, there is no MAJOR filter. It is not a date-ordered lookup; see [release-branches.md](../../shared-resources/release-branches.md#previous-release-tag-lookup). The MAJOR/MINOR/PATCH classification criteria above are unchanged regardless of branch. diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md index 29bc58394..5e66a1637 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -18,6 +18,27 @@ Use the shared [release branch reference](../shared-resources/release-branches.m Work through each step sequentially. Present findings at each step and get user confirmation before proceeding. Skip any step that has no applicable items. +### Step 0: Sync With Upstream + +Every later step reads branches, tags, and file contents from the local repository. Stale local refs +produce assessments that are wrong in ways that look plausible: a missing tag makes a released +version invisible, and a stale branch hides merged PRs. Establish a complete, current view before +reading anything. + +1. Identify the remote that points at the canonical repository (`modelcontextprotocol/csharp-sdk`). + Do not assume it is named `origin` — in a fork-based checkout `origin` is often the fork: + `git remote -v` +2. Fetch that remote's branches **and tags**, pruning deleted refs: + `git fetch {upstream} --prune --prune-tags --tags` +3. Confirm the tag for the most recent published release exists locally and resolves: + `git rev-parse --verify v{previous}^{commit}` + +Report what changed as a result of the fetch — new tags, updated branch heads — so the user can see +whether the starting state was stale. + +Read every subsequent step's branch state from the remote-tracking refs (`{upstream}/main`, +`{upstream}/release/{MAJOR}.x`), not from local branches, which may lag or have diverged. + ### Step 1: Select Source Branch List candidate source/base branches via: @@ -40,9 +61,21 @@ The user may provide: - **No context** — show the last 5 commits on the selected source/base branch (noting HEAD) and offer the option to enter a branch or tag name instead Once the target is established: -1. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). Use the selected source/base branch context: on `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. +1. Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the target commit (exclude drafts with `--exclude-drafts`). Do not order by publication date; see [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup) for why the two differ and what breaks. On `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, there is no MAJOR filter. 2. Get the full list of PRs merged between the previous release tag and the target commit on the selected branch. 3. Read `src/Directory.Build.props` **at the target commit**. Extract `` and ``; the **candidate version** is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). +4. **Verify the previous release tag is an ancestor of the target commit:** + `git merge-base --is-ancestor v{previous} {target}` + + If it is not an ancestor, stop and report. The two histories have diverged, which means the + selected source branch is not a continuation of the previous release. Every downstream + conclusion would be wrong: the PR range would be computed across unrelated history, and the + ApiCompat baseline in Step 7 would report the previous release's entire API surface as removed. + This is a source-selection problem, not a compatibility problem — do not attempt to suppress it. + + The usual cause is that the previous release shipped from a different branch than the one + selected. Re-run Step 1 and choose the branch that actually contains the previous release, or + confirm with the user that a divergent source is intended and why. ### Step 3: Categorize and Attribute @@ -98,7 +131,7 @@ After the version is confirmed: 2. Update `src/Directory.Build.props`: - Set `` to the confirmed stable component - Set `` for prerelease versions, or clear it for stable versions; add the element if it is missing - - Update `` when appropriate. For the `2.0.0-preview` series, baseline is `1.3.0` (latest shipped 1.x). For subsequent stable releases, baseline is the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR. + - Update `` when appropriate, per the rule in [references/apicompat-apidiff.md](references/apicompat-apidiff.md#updating-the-baseline-version). Read the current value from `src/Directory.Build.props` and derive the correct one from the versions actually published; never copy a version from an example. Show the derivation — current value, published versions considered, resulting value, and whether it changes — and get confirmation before editing. **If the value changes, the [baseline-transition suppression audit](references/apicompat-apidiff.md#baseline-transition-suppression-audit) is mandatory.** 3. Build the solution to verify the version change compiles: `dotnet build` This step creates local changes only — nothing is committed or pushed yet. @@ -109,11 +142,19 @@ Run API compatibility validation against the baseline version. Follow [reference 1. Run `dotnet pack` to trigger package validation against `PackageValidationBaselineVersion` 2. Capture the ApiCompat output (compatibility issues, warnings, suppressions) -3. If there are unexpected compatibility breaks: +3. **If `PackageValidationBaselineVersion` changed in this release, run the baseline-transition suppression audit before interpreting anything else.** Moving the baseline makes suppressions written for the old baseline stale, and the resulting failure looks exactly like a mass breaking change. +4. If there are unexpected compatibility breaks: + - **First, check whether the output says `Unnecessary suppressions found`.** That is a hard failure in its own right, and the CP0001/CP0002/CP0005 lines beneath it are the listing of *unused suppression entries*, not live API breaks. Regenerate the suppression file and cross-check the API diff before believing them. + - **Then sanity-check the scale.** A large number of errors reporting *missing* API surface — + especially spanning whole feature areas — almost always means the baseline does not belong to + this branch's history, or that stale suppressions are being listed. Re-verify the ancestry check + from Step 2 before interpreting a single error. Never suppress your way out of this. - Cross-reference with the breaking change audit from Step 4 - Present any unaccounted breaks to the user - - If breaks are intentional, add appropriate entries to `CompatibilitySuppressions.xml` in the affected project directory -4. Record the ApiCompat results for inclusion in the PR description + - If breaks are intentional, add appropriate entries to `CompatibilitySuppressions.xml` in the affected project directory — only after the suppression audit is complete +5. **Never adjust the thing being validated against in order to pass.** Do not change `PackageValidationBaselineVersion` to silence errors, do not set `ApiCompatPermitUnnecessarySuppressions`, do not `NoWarn` CP diagnostics, and do not disable package validation. The baseline is determined by what shipped; suppressions record user-confirmed intentional breaks. If validation fails unexpectedly, stop and report. +6. Confirm the plain CI-equivalent run passes with no generation flags: `dotnet clean -c Release; dotnet pack -c Release` +7. Record the per-package ApiCompat results — baseline, generated entry count, retained/removed suppressions, plain pack result — for Step 12 and the PR description ### Step 8: Generate API Diff Report @@ -154,19 +195,63 @@ Stage all documentation changes for inclusion in the release commit. Compose the release notes that will appear in the PR description and serve as the foundation for the **publish-release** skill. This is a draft — the final release notes will be refreshed when the GitHub release is created. 1. **Preamble** — Draft a short paragraph summarizing the release theme. Present it to the user for review and editing. The preamble is **required**. -2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link. +2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link, using the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links). 3. **What's Changed** — chronological; includes breaking change PRs 4. **Documentation Updates** — chronological 5. **Test Improvements** — chronological 6. **Repository Infrastructure Updates** — chronological 7. **Acknowledgements**: - New contributors (first contribution in this release) - - Issue reporters (cite resolving PRs) + - Issue reporters (cite resolving PRs) — **excluding maintainers**. Acknowledgements exist to + thank the community; a maintainer filing an issue in their own repository is ordinary + project work, not a contribution to credit. Determine maintainer status via + `gh api repos/{owner}/{repo}/collaborators/{user}/permission --jq .permission` and omit + anyone with `admin` or `write`. Maintainers still appear in the reviewers bullet. - PR reviewers (single bullet, sorted by review count, no count shown) 8. **Full Changelog** link using the exact tag, including any suffix (for example, `v1.3.1` or `v2.0.0-preview.1`) Omit empty sections. Present each section for user review before proceeding. Tag references in templates use `v{version}` exactly, including prerelease suffixes; the Full Changelog link compares the previous tag to the suffixed tag when applicable. +### Step 10b: Review Categorization and Acknowledgements With the User + +**Do this before committing, and never defer it to the Step 12 summary.** Showing the finished +notes is not a substitute for this step. A complete, well-formatted set of release notes reads as +correct and does not invite scrutiny; users routinely approve it and then find miscategorized +entries afterward, once the PR is already open. Ask targeted questions while the answers are still +cheap to apply. + +Present two compact review artifacts and stop for a response after each. + +**1. Categorization table.** Every PR, its assigned section, and the reason — not just the +borderline ones, since the user cannot correct a call they were not shown: + +| PR | Title | Section | Why | +|---|---|---|---| +| #{number} | {title} | {section} | {what the placement turned on} | + +Then explicitly surface the judgment calls, naming the PRs and the reasoning that made each one +close: + +> These were the close calls: {PRs} touch code but not shipped packages, so I placed them under +> {section}. Any of these belong in a different section? + +Flag as a close call any PR that touches `samples/` or `tests/` but not `src/`, any PR placed in +"What's Changed" whose changes are confined to non-shipping paths, and any PR whose title suggests +a different section than the one you assigned. + +**2. Acknowledgements roster.** Each person, why they are listed, and their maintainer status: + +| Person | Reason | Maintainer? | +|---|---|---| +| @{handle} | {contribution or issue, and the PR that resolved it} | {yes/no — if yes, omit per Step 10 item 7} | + +Show entries you excluded and why, so the user can overrule the omission. Ask directly whether the +remaining list is right, since acknowledgement errors are about people and are the least +comfortable thing to correct after publication. + +Apply any corrections before Step 11. Record what changed so the same misclassification is not +reintroduced when publish-release refreshes the notes for late-arriving PRs. + ### Step 11: Commit Changes Commit all changes to the `release-{version}` branch: @@ -191,7 +276,7 @@ Present **all** of the following details to the user for review. The user must c docs/experimental.md — Added new experimental API reference ``` 6. **Draft release notes** — the complete release notes from Step 10 -7. **API Compatibility results** — the ApiCompat output from Step 7 +7. **API Compatibility results** — the per-package table from Step 7: baseline version, generated suppression count, retained/removed stale suppressions, and plain-pack result. Do not state that ApiCompat passed without these. Call out any change to `PackageValidationBaselineVersion` or to any suppression file explicitly. 8. **API Diff report** — the API diff from Step 8 9. **Proposed PR title** (e.g., `Release v2.0.0-preview.1`, `Release v1.3.1`) 10. **Proposed PR description** — the assembled content combining release notes, ApiCompat, and ApiDiff @@ -199,6 +284,11 @@ Present **all** of the following details to the user for review. The user must c After presenting all details, explicitly ask the user: > Would you like to push the branch and create the pull request? +Confirm the Step 10b review actually happened before asking. If categorization and acknowledgements +were never reviewed as their own decisions, go back and do that first — this gate is about +publishing mechanics, and burying content questions in it is how miscategorized entries reach an +open PR. + **Do not proceed without explicit "yes" confirmation.** ### Step 13: Push Branch and Create Pull Request @@ -213,12 +303,22 @@ Only after explicit user confirmation in Step 12: - **Description**: The assembled PR description (see PR Description Template below) - **Labels**: Apply appropriate labels (e.g., `release`) 3. Present the PR URL to the user +4. **Monitor CI to completion.** Creating the PR does not end this step. Watch every check on the new head SHA until it reaches a terminal state: + ```sh + gh pr checks {pr-number} --watch + ``` + Then report a per-check table and an overall verdict of **green**, **running**, or **blocked**. Do not hand off with only the PR URL and an invitation to review — the user should not be the one to discover a red build. +5. **On failure**, retrieve the logs yourself (`gh run view {run-id} --log-failed`), distinguish product/API validation failures from infrastructure or tooling flakiness, and diagnose before proposing a rerun. For ApiCompat failures, apply the interpretation rules in [references/apicompat-apidiff.md](references/apicompat-apidiff.md) before concluding the release is breaking. Present the diagnosis and a proposed fix, then stop — pushing a fix needs the same explicit approval as the original push. +6. **Restart monitoring after every subsequent push** to the release branch, against the new head SHA. Checks from a previous SHA are stale and must not be reported as current. **Important**: No draft GitHub release is created at this point. The **publish-release** skill handles release creation after this PR is merged. ## Edge Cases -- **PR spans categories**: categorize by primary intent +- **PR spans categories**: categorize by primary intent, and surface it as a close call at Step 10b +- **PR adds sample code or tests but no `src/` changes**: Documentation Updates or Test Improvements, not "What's Changed" — the shipped packages did not change +- **Issue reporter is a maintainer**: omit the acknowledgement; show it as an exclusion at Step 10b so the user can overrule +- **User recategorizes after the PR is open**: update the PR body, and record the correction so publish-release does not re-derive the original category - **Copilot timeline missing**: fall back to `Co-authored-by` trailers to determine whether `@Copilot` should be a co-author; if still unclear, use `@Copilot` as primary author - **No breaking changes**: omit the Breaking Changes section from release notes entirely - **Single breaking change**: use the same numbered format as multiple @@ -227,12 +327,21 @@ Only after explicit user confirmation in Step 12: - **Proposed MAJOR does not match branch MAJOR**: if the proposed version's MAJOR doesn't match the branch's MAJOR (for example, proposing `2.0.0-preview.2` on `release/1.x`), flag this as a warning and ask the user to confirm. Do not hard-fail. This is informational, not a policy enforcement. - **Prerelease bump**: when the candidate version has a suffix like `preview.N`, the SemVer assessment may simply increment `N` rather than computing MAJOR/MINOR/PATCH. Refer to the SemVer assessment guide's Prereleases section. - **No previous release**: if this is the first release, there is no previous tag; gather all PRs merged to the target +- **Previous release tag is not an ancestor of the target**: stop and re-select the source branch per Step 2. Do not compute a PR range or interpret ApiCompat results across divergent history, and do not suppress the resulting errors +- **Previous release tag missing locally**: re-run the Step 0 fetch with `--tags` before concluding the release does not exist; a tag absent locally is far more often a stale checkout than an unpublished release - **ApiCompat tooling unavailable**: fall back to `dotnet pack` output; note in the PR description that full ApiCompat was run via package validation only +- **`Unnecessary suppressions found` in ApiCompat output**: the CP lines that follow are unused suppression entries, not live breaks. Run the baseline-transition suppression audit and cross-check the API diff before treating the release as breaking +- **Baseline version changed during preparation**: run the suppression audit for every shipping package, and decide deliberately between advancing the baseline (clearing stale suppressions) and keeping the existing one. Report the choice and its rationale at Step 12 +- **ApiCompat passes locally but CI fails**: check whether local runs used generation flags. Only `dotnet clean -c Release; dotnet pack -c Release` reproduces CI +- **A check never starts**: a workflow skipped by a path filter or stuck in a queue is not a pass. Compare against the check set on previous release PRs before declaring green +- **Checks green on an earlier SHA**: stale. Re-watch against the current head after every push +- **CI fails for infrastructure reasons**: a single rerun is reasonable if the cause is clearly runner, network, or feed related. State the reason. Never rerun a product or API validation failure to make it disappear - **API diff tool installation fails**: do not fall back to a manual summary; pause and present the installation error to the user, offering options to troubleshoot, skip the API diff section, or abort the release preparation - **No changelogs in repo**: skip changelog updates; note in the summary - **Branch already exists**: if `release-{version}` already exists locally or remotely, ask the user whether to reuse it, delete and recreate, or choose a different name -- **PackageValidationBaselineVersion update**: for the `2.0.0-preview` series, use `1.3.0`; for subsequent stable releases, use the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR -- **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit; existing suppressions should be preserved +- **PackageValidationBaselineVersion update**: derive it per [references/apicompat-apidiff.md](references/apicompat-apidiff.md#updating-the-baseline-version) from the versions actually published, and show the derivation for confirmation. A change to this property makes the baseline-transition suppression audit mandatory +- **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit. Preserve existing suppressions **unless the baseline moved** — the audit may prove tracked entries stale, in which case removing them is the fix, not a regression +- **Versioning link for a brand-new MAJOR**: the `/v{MAJOR}/versioning.html` path does not exist until the release is published and the Publish Docs workflow runs. The link is forward-referencing at prepare time, like the release-notes tag link. Use the slugged form anyway; do not fall back to the unslugged URL. - **User declines PR creation**: if the user declines at Step 12, leave the local branch intact so they can review, modify, or push manually ## PR Description Template @@ -248,7 +357,7 @@ The PR description combines release notes, ApiCompat, and ApiDiff into a single ### Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. 1. **Description #PR** * Detail of the break @@ -303,14 +412,14 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers The release notes section within the PR description uses the same format as the final GitHub release notes (used by the **publish-release** skill). This ensures consistency between the PR and the published release. Tag examples such as `v2.0.0-preview.1` are valid and should be used verbatim when the version has a prerelease suffix. -Omit empty sections. The preamble is **always required** — it is not inside a section heading. +Omit empty sections. The preamble is **always required** — it is not inside a section heading. The versioning link uses the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links). ```markdown [Preamble — REQUIRED. Summarize the release theme.] ## Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. 1. **Description #PR** * Detail of the break diff --git a/.github/skills/prepare-release/references/apicompat-apidiff.md b/.github/skills/prepare-release/references/apicompat-apidiff.md index 945df1a0f..78d1d13b3 100644 --- a/.github/skills/prepare-release/references/apicompat-apidiff.md +++ b/.github/skills/prepare-release/references/apicompat-apidiff.md @@ -8,16 +8,16 @@ The SDK uses NuGet's [Package Validation](https://learn.microsoft.com/dotnet/fun ```xml true -1.0.0 +{baseline} ``` +Read the current values rather than assuming them, and check whether any individual project overrides them — a project that opts out of validation still ships, and needs to be reported as unvalidated rather than quietly skipped. + ### Running ApiCompat -1. **Pack the SDK packages** to trigger validation: +1. **Pack the SDK packages** to trigger validation. Enumerate the packable projects under `src/` and pack each one; the set of shipping packages grows over time, so do not work from a remembered list: ```sh - dotnet pack src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj - dotnet pack src/ModelContextProtocol/ModelContextProtocol.csproj - dotnet pack src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj + dotnet pack src/{project}/{project}.csproj ``` Or pack all at once: ```sh @@ -28,17 +28,96 @@ The SDK uses NuGet's [Package Validation](https://learn.microsoft.com/dotnet/fun 3. **Interpret results:** - **No issues**: The API is backward-compatible with the baseline. This is the expected result for PATCH and MINOR releases. + - **`Unnecessary suppressions found`**: **Read this before concluding anything else.** See [Reading a failing run](#reading-a-failing-run) below — the CP lines that follow it are usually *not* live breaks. - **Compatibility errors**: The API has breaking changes relative to the baseline. These should align with the breaking change audit from Step 3 of the prepare-release skill. - - **Suppressions needed**: If intentional breaking changes are confirmed, add entries to `CompatibilitySuppressions.xml` in the affected project directory. + - **Suppressions needed**: If intentional breaking changes are confirmed, add entries to `CompatibilitySuppressions.xml` in the affected project directory — but only after completing the [baseline-transition suppression audit](#baseline-transition-suppression-audit). + +### Reading a failing run + +`Unnecessary suppressions found` is itself a **hard failure**, not a warning attached to some other +problem. When it appears, the `CP0001` / `CP0002` / `CP0005` lines printed after it are the tool's +**detailed listing of the suppression entries it considers unused**. They are not a list of live API +breaks, even though they are formatted identically and appear under the same error banner. + +Misreading that listing is how a routine release turns into a phantom emergency. In this repo it +produced 312 apparent breaking changes across the Core Tasks API on a release whose only real +change was one additive method — and it did so convincingly, because 312 lines of CP0001 for +missing types reads exactly like a catastrophic regression. + +Before classifying a release as breaking, confirm which of the two you are looking at: + +1. **Regenerate the suppression file** (see the audit below). If the generated output is *empty*, + there are no live breaks and every tracked entry is stale. +2. **Cross-check the direct API diff.** If ApiDiff shows only the additions you expect, the CP lines + are not describing reality. + +Never work around this with `ApiCompatPermitUnnecessarySuppressions`, `NoWarn` for CP diagnostics, +or by disabling baseline validation. Those hide the signal that tells you the suppressions and the +baseline have drifted apart, which is the one thing you need to know. ### Updating the Baseline Version - **MAJOR version bump**: Update `` to the previous release version so that ApiCompat validates against the last stable release of the prior MAJOR version. After the new MAJOR release is published, the baseline stays at the new version for future comparisons. - **MINOR or PATCH version bump**: Keep `` at the last MAJOR release version (e.g., keep `1.0.0` when releasing `1.1.0` or `1.0.1`). +**A baseline that trails `VersionPrefix` is the expected steady state, not a stale value.** Through a MAJOR series the baseline deliberately stays put while `VersionPrefix` advances, so seeing `2.0.0` alongside a published `2.1.0` means the rule is being followed. Do not "fix" the gap — bumping the baseline mid-series triggers the audit below and invites the released API surface to be re-baselined against itself, silently discarding the compatibility guarantee the property exists to enforce. + +**Any change to this property triggers the [baseline-transition suppression audit](#baseline-transition-suppression-audit).** Do not change it and interpret the resulting failures as breaking changes — the failures are expected until the suppressions are reconciled. + +### Baseline-transition suppression audit + +**Whenever `PackageValidationBaselineVersion` changes, run this audit before interpreting any +ApiCompat failure and before adding a single suppression entry.** + +Suppression entries are scoped to the baseline they were generated against. They record "this +difference from *that* baseline is intentional." Move the baseline and the differences change, so +entries written for the old baseline may describe nothing at all — the API they excused is now +present on both sides. The tool reports those orphans as unnecessary, and the build fails. + +For **every shipping project**: + +1. Inventory the tracked suppressions: + ```sh + ls src/*/CompatibilitySuppressions.xml + ``` +2. Regenerate what the *current* baseline actually requires, into a throwaway file so the tracked + one is not overwritten while you are still deciding: + ```sh + dotnet clean src/{Project}/{Project}.csproj -c Release + dotnet pack src/{Project}/{Project}.csproj -c Release \ + /p:ApiCompatGenerateSuppressionFile=true \ + /p:ApiCompatSuppressionOutputFile={unique-temp-path} + ``` + Use the **final candidate version and the final baseline** — regenerating against a version you + are about to change invalidates the result. +3. Compare the generated entries against the tracked file, by count and by content. + +| Generated | Tracked | Meaning | Action | +|---|---|---|---| +| Empty | Non-empty | Every tracked entry is stale for this baseline | Clear or delete the tracked file | +| Non-empty | Matches | Suppressions are current | Leave them alone | +| Non-empty | Differs | Some entries stale, some breaks genuinely need suppressing | Reconcile entry by entry, and confirm each remaining break with the user | + +4. After clearing stale entries, rerun the plain CI-equivalent pack with no generation flags, and + require it to pass on its own: + ```sh + dotnet clean -c Release + dotnet pack -c Release + ``` + +Reverting the baseline is the other valid resolution, and sometimes the better one — it keeps the +release diff minimal. Choose deliberately between "advance the baseline and clear the stale +suppressions" and "keep the existing baseline", rather than letting the choice be made by whichever +one silences the error first. Either way, the baseline is determined by what shipped, never selected +to make validation pass. + ### Compatibility Suppressions -When intentional breaking changes are confirmed, create or update `CompatibilitySuppressions.xml` in the affected project directory. The repo already uses this pattern — see `src/ModelContextProtocol.Core/CompatibilitySuppressions.xml` for examples. +When intentional breaking changes are confirmed, create or update `CompatibilitySuppressions.xml` in the affected project directory — the conventional location, which is auto-discovered. + +**A suppression file has three valid outcomes, not one.** Entries get *added* when a new intentional break needs suppressing, *retained* when they still describe a real break against the current baseline, and *cleared* when the baseline moved and they no longer describe anything. Treating the file as append-only is what turned 312 obsolete entries into a release-blocking failure that read as a mass breaking change. Preservation is the default only while the baseline holds still; once it moves, the [audit](#baseline-transition-suppression-audit) decides what stays, and removing entries it proves stale is the fix rather than a regression. + +Do not use a tracked file as a template for what entries should look like — it may legitimately be empty, and its contents describe whatever baseline it was generated against, not yours. Generate entries instead. ```xml @@ -53,7 +132,28 @@ When intentional breaking changes are confirmed, create or update `Compatibility ``` -The exact suppression entries are generated by the pack command when it reports errors — copy the suggested suppression XML from the build output. Remember that suppressions are needed **per target framework** (net10.0, net9.0, net8.0, netstandard2.0). +The exact suppression entries are generated by the pack command when it reports errors — copy the suggested suppression XML from the build output, or generate the file directly with `/p:ApiCompatGenerateSuppressionFile=true`. Remember that suppressions are needed **per target framework** (net10.0, net9.0, net8.0, netstandard2.0). + +#### Wiring the suppression file + +A `CompatibilitySuppressions.xml` sitting in the project directory is **auto-discovered**. That is +the convention this repo uses, and it needs no wiring at all. Do not add MSBuild properties or items +to point at a file that is already found by convention — duplicate or incorrect wiring is easy to +add while chasing a failure and hard to spot afterward, and it ships in the release commit. + +If you do need an explicit path: + +| Name | Kind | Use | +|---|---|---| +| `CompatibilitySuppressionFilePath` | **Property** | The supported way to point at a suppression file explicitly | +| `ApiCompatSuppressionFile` | **Item** | Not a property. Setting it via `/p:` does nothing | +| `ApiCompatSuppressionOutputFile` | **Property** | Where `ApiCompatGenerateSuppressionFile=true` writes its output | + +Retaining an empty suppressions file is fine when you want to keep the file in place after clearing +stale entries. It must still be **valid XML** — an empty `` root, not a zero-byte +file — and it must preserve the repository's byte conventions for these files, including the +UTF-8 BOM and the final newline. A file that differs only in BOM or trailing newline produces a +confusing diff and can trip tooling that round-trips it. ### Common Diagnostic IDs @@ -191,14 +291,37 @@ _or_ [Diff or table of changes] ``` -### In the User Summary (Step 11) +### In the User Summary (Step 12) -Present a condensed version for the user review: +Present a condensed version for the user review. **Report every shipping package, and do not state +that ApiCompat passed without these four facts** — "passed" is not meaningful without knowing what +it was validated against and whether stale suppressions were masking or manufacturing the result: -- **ApiCompat**: pass/fail with count of issues and suppressions per package -- **ApiDiff**: count of additions, removals, and changes per package +| Package | Baseline | Generated entries | Retained / removed | Plain pack | +|---|---|---|---|---| +| {package} | {baseline} | 0 | 0 retained / 312 removed | ✅ | +| {package} | {baseline} | 0 | 0 / 0 | ✅ | + +Enumerate the packable projects under `src/` rather than working from a remembered list; the set +grows. A package that does not participate in validation still gets a row, reporting why — a first +release has no baseline to compare against, and that is a fact worth stating rather than an absence +worth hiding. + +- **Baseline** — the `PackageValidationBaselineVersion` actually used, and whether it changed during + this release +- **Generated entries** — count from `ApiCompatGenerateSuppressionFile=true` at the final version + and baseline +- **Retained / removed** — tracked suppressions kept versus cleared as stale +- **Plain pack** — result of the CI-equivalent `dotnet clean -c Release; dotnet pack -c Release` + with no generation flags, which is the run CI will reproduce + +Then the summary lines: ``` -API Compatibility: ✅ All 3 packages pass (2 existing suppressions in Core) +API Compatibility: ✅ All {n} packages pass against v{baseline} ({n} stale suppressions removed from {package}) API Diff: +12 additions, -2 removals, ~3 changes across all packages ``` + +If the baseline changed, or any suppression file was modified, say so explicitly and explain why. +A silent baseline or suppression edit is the kind of change that passes local validation and then +fails CI. diff --git a/.github/skills/prepare-release/references/categorization.md b/.github/skills/prepare-release/references/categorization.md index 844566759..55d2570b1 100644 --- a/.github/skills/prepare-release/references/categorization.md +++ b/.github/skills/prepare-release/references/categorization.md @@ -11,14 +11,30 @@ Feature work, bug fixes, API improvements, performance enhancements, and any oth - Changes that span code + docs (categorize based on the primary intent) ### Documentation Updates -PRs whose **sole purpose** is documentation. Examples: +PRs whose **sole purpose** is documentation, guidance, or examples. Examples: - Fixing typos in docs - Adding or improving XML doc comments (when not part of a functional change) - Updating conceptual documentation (e.g., files in `docs/`) - README updates - Adding CONTRIBUTING.md or similar guides +- **Adding or improving samples** under `samples/`, including new executable sample projects +- Clarifying how consumers should use existing behavior, even when the PR touches tests to + demonstrate or lock in that behavior -**Important**: A PR that changes code AND updates docs should go in "What's Changed" — only pure documentation PRs belong here. However, documentation PRs should still be studied during the breaking change audit, as they may document changes that were not properly flagged as breaking. +**The test that matters is whether the shipped packages changed**, not whether the PR contains +code. A PR that adds a whole new sample application is still a documentation update: nothing in +`src/` shipped differently because of it. Ask "would a consumer upgrading the NuGet package +observe any difference?" If no, it belongs here. + +**Important**: A PR that changes shipped product code under `src/` AND updates docs should go in +"What's Changed" — only PRs that leave the shipped surface untouched belong here. However, +documentation PRs should still be studied during the breaking change audit, as they may document +changes that were not properly flagged as breaking. + +Categorize conservatively: when a PR could plausibly land in either "What's Changed" or +"Documentation Updates", prefer "Documentation Updates" and surface the call to the user at the +categorization review. Overstating a docs PR as product work inflates the apparent scope of a +release, and it is the error users notice and correct. ### Repository Infrastructure Updates PRs that maintain the development environment but don't affect the shipped product or test coverage. Examples: @@ -114,3 +130,6 @@ Sort entries within each section by **merge date** (chronological order, oldest * @user submitted issue #1234 (resolved by #5678) * @user1 @user2 @user3 reviewed pull requests ``` + +Do not acknowledge maintainers as issue reporters or new contributors; see Step 10 item 7. They +belong only in the reviewers bullet. diff --git a/.github/skills/publish-release/SKILL.md b/.github/skills/publish-release/SKILL.md index 173bb2064..be4d19b74 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -16,6 +16,19 @@ Use the shared [release branch reference](../shared-resources/release-branches.m Work through each step sequentially. Present findings at each step and get user confirmation before proceeding. +### Step 0: Sync With Upstream + +This skill reads the merged release PR, the commit range since the previous release, and the +previous release tag. All three come from local refs that may be stale — most importantly, the +merge commit for the release PR will not exist locally until you fetch. + +1. Identify the remote pointing at `modelcontextprotocol/csharp-sdk` (`git remote -v`) — do not + assume it is `origin`. +2. `git fetch {upstream} --prune --prune-tags --tags` +3. Confirm the merged release PR's merge commit resolves locally. + +Report anything the fetch changed before continuing. + ### Step 1: Identify the Prepare-Release PR The user may provide: @@ -31,7 +44,7 @@ Verify the PR is merged. Extract: ### Step 2: Determine Version and Commit Range 1. Read `src/Directory.Build.props` at the merge commit to confirm `` and ``. The tag is `v{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present; for example, `2.0.0` + `preview.1` → `v2.0.0-preview.1`. -2. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). The lookup is branch-aware: when the merge commit is on a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md). +2. Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the merge commit (exclude drafts with `--exclude-drafts`). Do not order by publication date. When the merge commit is on a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, there is no MAJOR filter. See [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup). 3. Identify the full commit range: previous release tag → merge commit. ### Step 3: Check for Additional PRs @@ -67,7 +80,9 @@ Re-categorize all PRs in the commit range (including any new ones from Step 3). 1. **Re-run the breaking change audit** using the **breaking-changes** skill if new PRs were found that may introduce breaks. Otherwise, carry forward the results from the prepare-release PR. 2. **Re-categorize** all PRs into sections (What's Changed, Documentation, Tests, Infrastructure). 3. **Re-attribute** co-authors for any new PRs by harvesting `Co-authored-by` trailers from all commits in each PR. -4. **Update acknowledgements** to include contributors from new PRs. +4. **Update acknowledgements** to include contributors from new PRs, excluding maintainers as issue reporters (see prepare-release Step 10 item 7). +5. **Carry forward the prepare-release categorization decisions.** If the user recategorized a PR or removed an acknowledgement during preparation, honor that. Re-deriving categories from scratch will silently reintroduce the exact corrections they already made. +6. **Review with the user** using the categorization table and acknowledgements roster from prepare-release Step 10b — at minimum for PRs new since preparation, and for any entry whose section you changed. Do not fold this into the Step 9 draft-creation gate. ### Step 5: Review README and Validate Code Samples @@ -81,7 +96,24 @@ Re-run the README content checklist from [../prepare-release/references/readme-c 2. **Snippet validation** -- Extract `csharp`-fenced code blocks from `src/PACKAGE.md` and `README.md`, build the temporary test project, and report results. Follow [../prepare-release/references/readme-snippets.md](../prepare-release/references/readme-snippets.md) for the full procedure. 3. **Delete** the temporary project after validation. -If issues are found, present them to the user with proposed fixes. Any fixes must be applied as a separate commit before the draft release is created. +If issues are found, present them to the user with proposed fixes. + +**Applying them is not a local commit.** The release PR is already merged, so its branch is gone; +fixes belong on the base branch this release ships from (`main` or `release/{MAJOR}.x`), which is +protected. Open a small PR for them, let CI run, and merge it — do not push to the base branch +directly, and do not amend or re-tag anything already reviewed. + +Then **re-target the draft release**, which is pinned to the previously approved merge commit and +therefore does not contain the fix: + +```sh +gh release edit v{version} --target {new-merge-commit-sha} +``` + +Regenerate the release notes afterward so the commit range covers the new PR, and re-run the Step 6 +section review for anything that changed. If the user prefers not to take the fix in this release, +that is a valid choice — leave the draft pinned where it is and note the deferred item, rather than +carrying a fix that the tag will not include. **Edge Cases:** - **Stale package closure** -- A package introduced between prepare-release and now may not be listed. Add it to `src/PACKAGE.md` and `README.md`. @@ -102,7 +134,7 @@ Highlight any changes from the prepare-release draft (new entries, reordered ent ### Step 7: Preamble -Every release **must** have a preamble — a short paragraph summarizing the release theme that appears before the first `##` heading. The preamble is not optional. The preamble may mention the presence of breaking changes as part of the theme summary, but the versioning documentation link belongs under the Breaking Changes heading (see template), not in the preamble. +Every release **must** have a preamble — a short paragraph summarizing the release theme that appears before the first `##` heading. The preamble is not optional. The preamble may mention the presence of breaking changes as part of the theme summary, but the versioning documentation link belongs under the Breaking Changes heading (see template), not in the preamble. That link must use the `v{MAJOR}` slug for the version being released. Extract the draft preamble from the prepare-release PR description and present it alongside a freshly drafted alternative (accounting for any new PRs). @@ -123,11 +155,65 @@ Display release metadata for user review: - **Target**: merge commit SHA, its message, the merge commit's branch (the prepare-release PR base), and the prepare-release PR link After confirmation: -- Create with `gh release create --draft {tag} --target {merge-commit-branch}` (always `--draft`), using the prerelease tag verbatim when present +- Create with `gh release create --draft {tag} --target {merge-commit-sha}` (always `--draft`), using the prerelease tag verbatim when present +- **Target the full commit SHA, never a branch name.** A draft sits unpublished until a human + reviews and publishes it, which can be hours. `--target` is resolved when the tag is created -- + at publish time, not now -- so a branch name silently re-resolves to whatever landed on that + branch in the meantime. The tag would then be cut at a commit nobody reviewed, and the release + notes would describe a different commit than the one shipped. The SHA you displayed above is the + commit the user approved; pass that exact SHA. - **Never publish.** If the user asks to publish, decline and instruct them to publish manually. +Pinning the SHA costs nothing, because a draft release does not create the git tag. GitHub stores +the target and creates the tag only when the release is published, so the tag remains uncreated and +the draft fully editable while it waits. + +That is also what makes a late-arriving commit easy to absorb. If the user decides to include work +that merged after the draft was created, do not delete and recreate the release: repoint it with +`gh release edit {tag} --target {new-commit-sha}`, then regenerate the release notes for the new +range and present them for approval again. Never move the target without revising the notes to +match -- a target change silently alters what shipped. + +Then hand off to the user with the publishing checklist: + +> The draft release is ready at {release URL}. Before publishing: +> +> 1. Review the release notes line by line — this is the last review before they are public. +> 2. Check **Set as a pre-release** if this is a prerelease. +> 3. Once you have signed off on the notes, **remove the AI-generated disclosure note** from the +> bottom of the body. It is there because the draft was AI-drafted; after your thorough review +> and sign-off, the published notes stand as your reviewed work. +> 4. Click **Publish release**. + +The disclosure is removed by the **user**, as part of their sign-off — never remove it yourself, and +never remove it from a pull request description, an issue, or a comment. If the user asks you to +edit the draft body after they have removed it, do not reintroduce it. + When the user requests revisions after the initial creation, always rewrite the complete body as a file — never perform in-place string replacements. See [references/formatting.md](references/formatting.md). +### Step 10: Watch for Publication + +Do not end the skill by asking the user to report back when they have published. Poll the release +until it is no longer a draft: + +```sh +gh release view v{version} --json isDraft,publishedAt,tagName,isPrerelease +``` + +Poll at a modest interval — this gate is human-paced and may span hours or a session boundary. Say +that you are watching rather than going silent. + +When `isDraft` becomes `false`: + +1. Record the publication time from `publishedAt`, not from when the poll noticed. +2. Confirm the tag that was actually created and whether the release was marked as a prerelease. + Both were the user's to set and cannot be inferred. +3. **Hand off to the verify-release skill immediately.** Publishing starts the Release and Publish + Docs workflows in parallel at that moment; verification that begins late misses them mid-flight. + +If the user reports publishing but the API still shows a draft, trust the API and say so — an +unsaved draft looks identical to a published release from the browser. + ## Edge Cases - **No new PRs since preparation**: proceed normally — the prepare-release notes are used as the foundation with no warnings @@ -139,18 +225,23 @@ When the user requests revisions after the initial creation, always rewrite the - **PR spans categories**: categorize by primary intent - **Copilot timeline missing**: fall back to `Co-authored-by` trailers to determine whether `@Copilot` should be a co-author; if still unclear, use `@Copilot` as primary author - **No breaking changes**: omit the Breaking Changes section entirely +- **Versioning link carried over from the prepare-release draft**: the draft may contain an unslugged or wrong-MAJOR versioning link. Correct it to the `v{MAJOR}` slug of the version being released before the draft release is created. +- **Versioning link for a brand-new MAJOR**: the `/v{MAJOR}/versioning.html` path is created by the Publish Docs workflow when the release is published. It is expected to 404 until then; use the slugged form regardless. - **Single breaking change**: use the same numbered format as multiple +- **Draft edited but not published**: the user is still reviewing, and may be removing the AI disclosure. Take no action and do not reintroduce anything they removed +- **Draft disappears without a published release**: it may have been deleted, or published under a different tag. Check for a published release before assuming it was abandoned, then ask +- **Published tag differs from the prepared version**: stop and confirm with the user before verifying. Verifying the wrong version is worse than not verifying ## Release Notes Template -Omit empty sections. The preamble is **always required** — it is not inside a section heading. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag. +Omit empty sections. The preamble is **always required** — it is not inside a section heading. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag. The versioning link uses the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links). ```markdown [Preamble — REQUIRED. Summarize the release theme.] ## Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. 1. **Description #PR** * Detail of the break diff --git a/.github/skills/publish-release/references/formatting.md b/.github/skills/publish-release/references/formatting.md index 467dbb3f2..888f3c5f4 100644 --- a/.github/skills/publish-release/references/formatting.md +++ b/.github/skills/publish-release/references/formatting.md @@ -27,7 +27,7 @@ When the user requests changes to existing release notes: 1. Fetch the current release body and save it to a local file 2. **Breaking change audit**: Run the full breaking-changes skill audit on the commit range, just as for new release notes — this includes examining PRs, reconciling labels, offering to comment on PRs, and getting user confirmation. Also extract any breaking changes already documented in the existing release body; these must be preserved and reconciled with the audit results. -3. **Preamble check**: Verify the release has a preamble (text before the first `##` heading). If missing, compose one. The versioning documentation link belongs under the `## Breaking Changes` heading, not in the preamble. +3. **Preamble check**: Verify the release has a preamble (text before the first `##` heading). If missing, compose one. The versioning documentation link belongs under the `## Breaking Changes` heading, not in the preamble, and must use the `v{MAJOR}` slug for the released version — see [release-branches.md](../../shared-resources/release-branches.md#versioning-documentation-links). 4. Write the **entire** corrected body to a separate local file (ensuring proper line breaks between all sections, entries, and paragraphs) 5. Run `git diff --no-index` between the original and updated files and **always** present the raw diff output directly in the response as a fenced code block with `diff` syntax highlighting. Do not summarize or paraphrase the diff — always show the complete diff to the user. Require explicit confirmation before uploading. For published releases (not drafts), also offer to save the original body to a permanent local file, noting that GitHub does not retain prior versions of release notes. 6. Upload the complete file using `gh release edit --notes-file ` @@ -47,8 +47,25 @@ After every release body update: - [ ] Preamble exists before the first `##` heading - [ ] If `## Breaking Changes` section exists, it begins with the versioning docs link paragraph before the numbered list +- [ ] The versioning docs link uses the `v{MAJOR}` slug for the released version (e.g. `/v2/versioning.html`), never the unslugged `/versioning.html` - [ ] Line count matches expected structure (~80+ lines for a typical release) - [ ] Section headings (`## Breaking Changes`, `## What's Changed`, etc.) each appear on their own line - [ ] Bullet entries are each on their own line - [ ] No stray characters at the start of the body - [ ] Preview the release on GitHub to confirm rendering + +## AI Disclosure + +A draft release body created by an agent carries a concise AI-generated disclosure at the bottom: + +```markdown +> [!NOTE] +> These release notes were drafted with GitHub Copilot and reviewed before publishing. +``` + +Keep it on the draft. Removing it is the **user's** decision, made as part of their final sign-off +once they have reviewed the notes line by line and are satisfied the content is theirs. Never remove +it on your own initiative, and never reintroduce it after the user has removed it. + +This exception applies only to release notes, which get a dedicated human review before publishing. +Disclosures on pull request descriptions, issues, and comments always remain. diff --git a/.github/skills/shared-resources/release-branches.md b/.github/skills/shared-resources/release-branches.md index 477567793..298776b6a 100644 --- a/.github/skills/shared-resources/release-branches.md +++ b/.github/skills/shared-resources/release-branches.md @@ -25,12 +25,61 @@ Official NuGet.org publishes happen only when a GitHub Release is created from a ## Previous-release tag lookup -- On `main`: most recent published release globally (use `gh release list --exclude-drafts --limit 50` and pick the highest semver). No MAJOR filter. -- On `release/{MAJOR}.x`: most recent published release whose tag matches `v{MAJOR}.*`. Drafts are excluded. +Select the **highest semver** among published releases that are **ancestors of the target commit**, +excluding drafts: + +```sh +gh release list --exclude-drafts --limit 50 +``` + +- On `main`: no MAJOR filter — the highest semver ancestor wins. +- On `release/{MAJOR}.x`: restrict candidates to tags matching `v{MAJOR}.*`. + +**"Highest semver" and "most recent by date" are not the same rule, and the difference is not +hypothetical.** Ship `v2.1.0` from `main`, then a `v2.0.1` servicing patch from `release/2.0.x`, and +the most recently *published* release is `v2.0.1` while the highest semver is `v2.1.0`. Ordering by +date picks a tag that is not on `main` at all, which produces a bogus PR range and makes ApiCompat +report the entire API surface as removed. Order by version, not by publication time. + +The ancestry constraint is what makes this safe across branches, so verify it rather than assuming +the version ordering implied it — a tag can be both the highest semver and unreachable from the +target. `prepare-release` Step 2 performs this check explicitly. This is purely a baseline-selection rule. It does **not** change the breaking-change policy. See [the versioning docs](https://csharp.sdk.modelcontextprotocol.io/versioning.html) for the policy. -## Work-branch naming +## Versioning documentation links + +The documentation site is published per major version under a `v{MAJOR}` slug (`/v1/`, `/v2/`). Any +link to the versioning documentation from **release notes** — both the release-notes link and the +paragraph under the `## Breaking Changes` heading — must point at the slugged instance for the +version being released: + +``` +https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html +``` + +The slug is derived from the **MAJOR component of the version being released**, not from the branch: + +| Version being released | Versioning link | +| ---------------------- | --------------- | +| `1.3.1` | `https://csharp.sdk.modelcontextprotocol.io/v1/versioning.html` | +| `2.0.0-preview.1` | `https://csharp.sdk.modelcontextprotocol.io/v2/versioning.html` | +| `2.0.0` | `https://csharp.sdk.modelcontextprotocol.io/v2/versioning.html` | + +The branch is normally consistent with this — `release/1.x` releases `1.x` versions and `main` +currently releases `2.x` — but the version is what determines the slug. If a release's MAJOR ever +disagrees with its branch's MAJOR, follow the version. + +Prerelease suffixes do not affect the slug: `2.0.0-preview.1` and `2.0.0` both use `/v2/`. + +The unslugged `https://csharp.sdk.modelcontextprotocol.io/versioning.html` redirects to the site's +default version, which tracks the newest release. It is therefore unstable for a published release's +notes — a later MAJOR would silently repoint it. Never use the unslugged form in release notes. + +**First release of a new MAJOR**: the `/v{MAJOR}/` path does not exist until the Publish Docs +workflow runs, which happens when the GitHub release is published. The link is forward-referencing +at prepare and publish time, exactly like the release-notes tag link, and resolves once the release +is published. The **verify-release** skill confirms it. Prepare-release work branches are named `release-{version}` (flat, hyphen-separated): - `release-2.0.0-preview.1` diff --git a/.github/skills/verify-release/SKILL.md b/.github/skills/verify-release/SKILL.md new file mode 100644 index 000000000..1f8a706b2 --- /dev/null +++ b/.github/skills/verify-release/SKILL.md @@ -0,0 +1,190 @@ +--- +name: verify-release +description: Verify a published release of the C# MCP SDK. Monitors the Release and Publish Docs workflows triggered by publishing a GitHub release, confirms the packages are listed on NuGet.org, and confirms the versioned documentation site reflects the release. Use when asked to verify a release, check whether a release published correctly, monitor the release or docs workflow, confirm packages on NuGet, or check whether the docs site updated. +compatibility: Requires gh CLI with repo access for workflow runs and releases, and network access to nuget.org and csharp.sdk.modelcontextprotocol.io. +--- + +# Verify Release + +Verify that a published release of `modelcontextprotocol/csharp-sdk` fully shipped. Publishing a +GitHub release triggers **two workflows in parallel**, and the release is not done until both have +succeeded and both of their outputs are confirmed live. + +| Workflow | File | Trigger | Produces | +|---|---|---|---| +| Release | [`.github/workflows/release.yml`](../../workflows/release.yml) | `release: published` | NuGet packages published to NuGet.org | +| Publish Docs | [`.github/workflows/docs.yml`](../../workflows/docs.yml) | `release: published` | The versioned docs site at | + +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles +and release tag conventions. + +> **Safety: This skill is read-only by default.** It inspects workflow runs, releases, and published +> artifacts. The only actions it may take are re-running a failed workflow or dispatching a docs +> refresh, and both require explicit user confirmation. + +## Process + +Work through each step sequentially. Present findings at each step and get user confirmation before +taking any action. + +### Step 1: Identify the Release + +The user may provide: +- **A version or tag** (e.g., `2.0.0-preview.1`, `v1.3.1`) — use directly +- **No context** — list recent releases with `gh release list --limit 10` and ask the user to select + +Confirm the release is **published**, not a draft: + +``` +gh release view {tag} --json tagName,isDraft,isPrerelease,publishedAt,targetCommitish,url +``` + +If the release is still a draft, **stop**. Neither workflow has run — nothing is published, and no +verification is possible. Tell the user the draft must be published in the GitHub UI first, and +that publishing is a deliberate human action this skill will not perform. + +Record the tag, the published timestamp, and the target commitish for the following steps. + +### Step 2: Locate Both Workflow Runs + +Find the runs triggered by publishing this release. A release-event run carries the **tag name in +`headBranch`**, which is an exact identifier — use it rather than correlating on timestamps: + +``` +gh run list --workflow release.yml --event release --branch v{version} --limit 5 --json databaseId,status,conclusion,headBranch,headSha,createdAt,url +gh run list --workflow docs.yml --event release --branch v{version} --limit 5 --json databaseId,status,conclusion,headBranch,headSha,createdAt,url +``` + +**Do not identify runs by "the most recent run" or "created at or after `publishedAt`."** Those +match any release published in the same window, so a concurrent or closely-following release — +including a servicing patch published from another branch minutes later — can be reported as this +release's result, showing a green run for the wrong tag. Confirm `headBranch` equals `v{version}` +on every run before evaluating it. + +Cross-check `headSha` against the release's target commitish recorded in Step 1. A mismatch means +the tag moved between drafting and publishing, and the run validated something other than what was +reviewed — stop and report it rather than evaluating the run. + +If more than one run matches the tag, the workflow was re-run; evaluate the **latest attempt** and +say that earlier attempts existed rather than silently reporting only the newest. + +Present both runs with their status, conclusion, and URL. Watch them **together** — they run +concurrently and either can fail independently. Do not report success for the release until both +are accounted for. + +If a run cannot be found for either workflow, report which one is missing and check whether the +workflow is disabled or whether its `if` repository guard excluded the run (both workflows only run +in the `modelcontextprotocol/csharp-sdk` repository, not in forks). + +### Step 3: Evaluate the Release Workflow + +Report the run's conclusion. If it failed, identify the failing job and step and summarize the +error: + +``` +gh run view {run-id} --log-failed +``` + +A failure here does **not** roll back the release — the GitHub release and its tag remain, and the +workflow is simply re-run once the cause is addressed. Re-running is safe and is usually the right +first move. Recommend it, but **do not re-run without explicit user confirmation**. + +> **Never run `dotnet nuget push` and never handle NuGet API keys.** Package publishing happens only +> through the workflow. + +### Step 4: Evaluate the Publish Docs Workflow + +Report the run's conclusion, accounting for these docs-specific behaviors: + +- **Superseded runs are not failures.** The workflow uses a `pages` concurrency group with + `cancel-in-progress: true`. Every run rediscovers the current releases and rebuilds the whole site + from scratch, so a newer run fully supersedes the one it cancels. Report a cancelled run as + *superseded* and follow the newer run instead. +- **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. +- **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 + release branch will not affect orchestration. + +If it failed, summarize the failing step. Common causes are a docs build failure in one version's +worktree (`make generate-docs`) or a Pages deployment error. + +### Step 5: Confirm the Published Packages + +Confirm the exact released version is listed for each shipping package on NuGet.org. + +Listing can lag a successful workflow run by several minutes. If the workflow succeeded but the +version is not yet visible, say so explicitly and offer to re-check — **do not report this as a +failure**. Distinguish "published but not yet indexed" from "not published." + +Report each package with its status, and flag any shipping package missing from the release. + +### Step 6: Confirm the Documentation Site + +Confirm reflects this release: + +1. **Version path** — the major-version path for this release (for example `/v2/`) is live and + serving the new content. +2. **Version picker** — the picker offers this release's major version. +3. **Root redirect** — the site root redirects to the expected default version, which is the newest + release by publish date, prereleases included. +4. **Versioning page** — the slugged versioning page for this release, + `https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html`, resolves. Release notes + link to it from the Breaking Changes section, and for the first release of a new MAJOR that path + only comes into existence with this workflow run. Confirm the release notes use the slugged form + and not the unslugged `/versioning.html`, which tracks the site default and can silently repoint + when a later MAJOR ships. + +GitHub Pages caches aggressively, so a short delay after a successful deploy is normal. +Distinguish "deployed but not yet propagated" from "deployed wrong." + +### Step 7: Report + +Summarize the verification as a table covering both workflows and both published outputs, and state +plainly whether the release is fully verified or what remains outstanding. + +| Check | Status | +|---|---| +| Release workflow | ✅ succeeded — {run URL} | +| Publish Docs workflow | ✅ succeeded — {run URL} | +| Packages on NuGet.org | ✅ {version} listed for all N packages | +| Docs site | ✅ `/v2/` live, picker updated, root redirects | + +## Remediation + +Both remediations require explicit user confirmation. + +**Re-run a failed workflow:** + +``` +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: + +``` +gh workflow run docs.yml --field docs_ref={branch-tag-or-commit} +``` + +The ref's major version, read from `src/Directory.Build.props`, must have a published release; the +workflow fails fast if it does not. This replaces only the matching major's HTML — orchestration +and all other versions are unaffected. + +## Edge Cases + +- **Release is still a draft** — stop; neither workflow has run. The user must publish in the GitHub UI. +- **Docs run cancelled** — expected under the `pages` concurrency group; report as superseded and follow the newer run. +- **Only one workflow ran** — check whether the other is disabled, or whether the repository guard excluded it (forks do not run either workflow). +- **Workflow succeeded but NuGet version not listed** — indexing lag; re-check before reporting a failure. +- **Workflow succeeded but docs not visible** — Pages caching; re-check before reporting a failure. +- **Docs site missing the new major version** — confirm the release is published and non-draft, then confirm the tag matches `v{MAJOR}.*`. +- **Root redirects to an unexpected version** — the default is the newest release *by publish date*, including prereleases. A prerelease published after a stable release becomes the default; this is by design. +- **Release workflow failed after partial publish** — some packages may already be on NuGet.org. NuGet versions cannot be unpublished; re-running skips already-published versions. Report exactly which packages are listed before recommending a re-run. +- **Versioning link is unslugged or points at the wrong MAJOR** — release notes must link to `/v{MAJOR}/versioning.html` for the released version. Report it so the user can correct the body; the unslugged form tracks the site default and will repoint when a later MAJOR ships. +- **Verifying an older release** — the docs workflow only ever reflects each major's *latest* release, so an older release's docs path will have been overwritten by a newer one. Verify packages only and note this. From f277062ab23da7a6853de17475589d0f5f5b2704 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:32:34 -0700 Subject: [PATCH 3/9] Bump Anthropic from 12.39.0 to 12.40.0 (#1808) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3d25bff04..69ed858b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -63,7 +63,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive From 7acbeb580ef4fdef789ae962b6906d55507e5d95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:47:20 -0700 Subject: [PATCH 4/9] Bump @hono/node-server from 1.19.14 to 2.1.0 in the npm_and_yarn group across 1 directory (#1812) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 73d7e0f09..774becc10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" From 149c5c47db82ee7a9eedfae3fbd6cf78854220cd Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:19:28 +1200 Subject: [PATCH 5/9] Fix dead relative link to versioning docs in bump-version skill (#1807) --- .github/skills/bump-version/references/semver-assessment.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/bump-version/references/semver-assessment.md b/.github/skills/bump-version/references/semver-assessment.md index e9f4508af..3d149780a 100644 --- a/.github/skills/bump-version/references/semver-assessment.md +++ b/.github/skills/bump-version/references/semver-assessment.md @@ -62,7 +62,7 @@ While the candidate version uses a prerelease suffix (e.g., `X.Y.Z-preview.N`, ` Going to GA drops the suffix entirely: `2.0.0-rc.2` → `2.0.0`. -This is purely about how to *compute* the next version. It does **not** declare any new policy about what kinds of changes are permitted between previews — refer to the existing [versioning documentation](../../../../docs/versioning.html) for breaking-change policy. +This is purely about how to *compute* the next version. It does **not** declare any new policy about what kinds of changes are permitted between previews — refer to the existing [versioning documentation](../../../../docs/versioning.md) for breaking-change policy. ### Branch context From 2912e94f928719d4218f0a98f57feb6d92e02281 Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:24:18 +1200 Subject: [PATCH 6/9] Fix duplicated word in test comment (#1809) --- .../Utils/KestrelInMemoryConnection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs index c632630b0..59cce6b38 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs @@ -92,7 +92,7 @@ public override Task FlushAsync(CancellationToken cancellationToken) protected override void Dispose(bool disposing) { - // Signal to the server the the client has closed the connection, and dispose the client-half of the Pipes. + // Signal to the server the client has closed the connection, and dispose the client-half of the Pipes. ThreadPool.UnsafeQueueUserWorkItem(static cts => ((CancellationTokenSource)cts!).Cancel(), connectionClosedCts); duplexPipe.Input.Complete(); duplexPipe.Output.Complete(); From bba45c44bf8397919c3af5b401aa752ee7697c0e Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:27:48 +1200 Subject: [PATCH 7/9] Fix McpHeaderEncoder.DecodeValue throwing on the degenerate base64 wrapper (#1805) --- .../Protocol/McpHeaderEncoder.cs | 3 ++- .../Client/McpHeaderEncoderTests.cs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs index 9366063e4..904f4db00 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs +++ b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs @@ -126,7 +126,8 @@ public static class McpHeaderEncoder // Check for Base64 wrapper. The spec requires the sentinel markers to be // case-sensitive and exactly lowercase per SEP-2243. - if (headerValue.StartsWith(Base64Prefix, StringComparison.Ordinal) && + if (headerValue.Length >= Base64Prefix.Length + Base64Suffix.Length && + headerValue.StartsWith(Base64Prefix, StringComparison.Ordinal) && headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal)) { var base64Content = headerValue.Substring( diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs index 26de71b45..1e4fc00ef 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs @@ -104,6 +104,16 @@ public void DecodeValue_ValidBase64_Decodes() Assert.Equal("Hello", result); } + [Fact] + public void DecodeValue_DegenerateWrapper_ReturnsLiteralValue() + { + // "=?base64?=" matches both the prefix "=?base64?" and the suffix "?=" because they + // overlap on the shared '?', but it is too short to contain any base64 content. It must be + // returned as-is rather than throwing when the wrapper is stripped. + var result = McpHeaderEncoder.DecodeValue("=?base64?="); + Assert.Equal("=?base64?=", result); + } + [Fact] public void DecodeValue_CaseSensitivePrefix_ReturnsLiteralValue() { From c6a97d94d66e8278b8e5db2032f5df4fc617f3c2 Mon Sep 17 00:00:00 2001 From: pardhu Date: Wed, 12 Aug 2026 23:36:36 -0700 Subject: [PATCH 8/9] Add HttpServerSessionMode for hybrid stateful/stateless HTTP serving (#1796) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jeff Handley --- docs/concepts/completions/completions.md | 2 +- docs/concepts/elicitation/elicitation.md | 2 +- .../elicitation/samples/server/Program.cs | 5 +- docs/concepts/filters.md | 8 +- docs/concepts/getting-started.md | 9 +- docs/concepts/httpcontext/samples/Program.cs | 3 +- .../logging/samples/server/Program.cs | 5 +- docs/concepts/mrtr/mrtr.md | 6 +- docs/concepts/pagination/pagination.md | 2 +- .../progress/samples/server/Program.cs | 3 +- docs/concepts/prompts/prompts.md | 2 +- docs/concepts/resources/resources.md | 6 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- docs/concepts/stateless/stateless.md | 91 +++-- docs/concepts/tools/tools.md | 2 +- docs/concepts/transports/transports.md | 15 +- docs/list-of-diagnostics.md | 2 +- .../AspNetCoreMcpPerSessionTools/Program.cs | 5 +- .../AspNetCoreMcpPerSessionTools/README.md | 6 +- samples/AspNetCoreMcpServer/Program.cs | 5 +- samples/EverythingServer/Program.cs | 5 +- samples/ProtectedMcpServer/Program.cs | 9 +- src/Common/Obsoletions.cs | 2 +- .../HttpServerSessionMode.cs | 64 ++++ .../HttpServerTransportOptions.cs | 83 ++++- .../IdleTrackingBackgroundService.cs | 2 +- .../McpEndpointRouteBuilderExtensions.cs | 6 +- .../StreamableHttpHandler.cs | 72 ++-- .../HttpServerTransportOptionsTests.cs | 68 ++++ .../July2026ProtocolHttpHandlerTests.cs | 8 +- .../July2026ProtocolHybridSessionModeTests.cs | 344 ++++++++++++++++++ .../July2026ProtocolStatefulFallbackTests.cs | 40 +- .../MapMcpTests.Mrtr.cs | 2 +- .../MrtrProtocolTests.cs | 2 +- .../RawHttpConformanceTests.cs | 2 +- .../RequestAbortCancellationTests.cs | 9 +- .../ResumabilityIntegrationTestsBase.cs | 4 +- .../SessionMigrationTests.cs | 2 +- .../Program.cs | 7 +- 40 files changed, 764 insertions(+), 150 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs diff --git a/docs/concepts/completions/completions.md b/docs/concepts/completions/completions.md index 16a16952e..0ac808ac5 100644 --- a/docs/concepts/completions/completions.md +++ b/docs/concepts/completions/completions.md @@ -26,7 +26,7 @@ Register a completion handler when building the server. The handler receives a r ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithPrompts() .WithResources() .WithCompleteHandler(async (ctx, ct) => diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 841c8a664..b899df200 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/elicitation/samples/server/Program.cs b/docs/concepts/elicitation/samples/server/Program.cs index b10dd7e74..e7b9e37f1 100644 --- a/docs/concepts/elicitation/samples/server/Program.cs +++ b/docs/concepts/elicitation/samples/server/Program.cs @@ -1,4 +1,5 @@ using Elicitation.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -8,8 +9,8 @@ .WithHttpTransport(options => { // Elicitation requires stateful mode because it sends server-to-client requests. - // Set Stateless = false explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since it's required. + options.SessionMode = HttpServerSessionMode.Stateful; }) .WithTools(); diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index aac23ea2a..80d6d4653 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -411,7 +411,7 @@ To enable authorization support, call `AddAuthorizationFilters()` when configuri ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .AddAuthorizationFilters() // Enable authorization filter support .WithTools(); ``` @@ -511,7 +511,7 @@ This allows you to implement logging, metrics, or other cross-cutting concerns t ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithRequestFilters(requestFilters => { requestFilters.AddListToolsFilter(next => async (context, cancellationToken) => @@ -546,6 +546,8 @@ services.AddMcpServer() To use authorization features, you must configure authentication and authorization in your ASP.NET Core application and call `AddAuthorizationFilters()`: ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication("Bearer") @@ -556,7 +558,7 @@ builder.Services.AddAuthorization(); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .AddAuthorizationFilters() // Required for authorization support .WithTools() diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 73901c14f..f3e2f581d 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -78,6 +78,7 @@ dotnet add package ModelContextProtocol.AspNetCore And add the following code: ```csharp +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.ComponentModel; @@ -85,10 +86,10 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // Stateless mode is recommended for servers that don't need - // server-to-client requests like sampling or elicitation. - // See the Stateless and Stateful documentation for details. - options.Stateless = true; + // Stateless mode is the default and recommended for servers that + // don't need server-to-client requests like sampling or elicitation. + // See the Stateless and Stateful documentation for details. + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithToolsFromAssembly(); var app = builder.Build(); diff --git a/docs/concepts/httpcontext/samples/Program.cs b/docs/concepts/httpcontext/samples/Program.cs index a01602d40..c9d9dc671 100644 --- a/docs/concepts/httpcontext/samples/Program.cs +++ b/docs/concepts/httpcontext/samples/Program.cs @@ -1,4 +1,5 @@ using HttpContext.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -7,7 +8,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/logging/samples/server/Program.cs b/docs/concepts/logging/samples/server/Program.cs index 48e2905c2..f7bc043cb 100644 --- a/docs/concepts/logging/samples/server/Program.cs +++ b/docs/concepts/logging/samples/server/Program.cs @@ -1,4 +1,5 @@ using Logging.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -8,8 +9,8 @@ .WithHttpTransport(options => { // Log streaming requires stateful mode because the server pushes log notifications - // to clients. Set Stateless = false explicitly for forward compatibility. - options.Stateless = false; + // to clients. Set SessionMode = HttpServerSessionMode.Stateful since it's required. + options.SessionMode = HttpServerSessionMode.Stateful; }) .WithTools(); // .WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult()); diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 8730e6f37..0d0fafa0a 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -29,7 +29,7 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `SessionMode = HttpServerSessionMode.Stateful` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. `HttpServerSessionMode.StatefulForInitializeClients` ([hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)) accepts `2026-07-28` statelessly — and therefore enables MRTR — while still issuing sessions to `initialize`-handshake clients on the same endpoint. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. ```csharp // Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. @@ -373,7 +373,7 @@ public static string CloseSupportTicket( ## Compatibility -The SDK supports `InputRequiredException` across two protocol revisions and two session modes: +The SDK supports `InputRequiredException` across protocol eras and effective request modes: | Negotiated protocol | Session mode | Behavior | |----------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| @@ -391,4 +391,4 @@ The SDK supports `InputRequiredException` across two protocol revisions and two Under `2025-11-25` and earlier, stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on `2026-07-28` stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. -Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. `HttpServerSessionMode.StatefulForInitializeClients` ([hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)) serves `2026-07-28` requests through the stateless path — the `2026-07-28` / `Stateless` row — while `initialize`-handshake clients on the same endpoint follow the `2025-11-25` / `Stateful` row. diff --git a/docs/concepts/pagination/pagination.md b/docs/concepts/pagination/pagination.md index 3276fcf3a..0f601921d 100644 --- a/docs/concepts/pagination/pagination.md +++ b/docs/concepts/pagination/pagination.md @@ -70,7 +70,7 @@ When implementing custom list handlers on the server, pagination is supported by ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithListResourcesHandler(async (ctx, ct) => { const int pageSize = 10; diff --git a/docs/concepts/progress/samples/server/Program.cs b/docs/concepts/progress/samples/server/Program.cs index cfff45808..ef9c67c64 100644 --- a/docs/concepts/progress/samples/server/Program.cs +++ b/docs/concepts/progress/samples/server/Program.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.AspNetCore; using Progress.Tools; var builder = WebApplication.CreateBuilder(args); @@ -7,7 +8,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/prompts/prompts.md b/docs/concepts/prompts/prompts.md index 062f02bdb..5dcc23662 100644 --- a/docs/concepts/prompts/prompts.md +++ b/docs/concepts/prompts/prompts.md @@ -63,7 +63,7 @@ Register prompt types when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithPrompts() .WithPrompts(); ``` diff --git a/docs/concepts/resources/resources.md b/docs/concepts/resources/resources.md index 6b08f7247..6080b2c0b 100644 --- a/docs/concepts/resources/resources.md +++ b/docs/concepts/resources/resources.md @@ -74,7 +74,7 @@ Register resource types when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithResources() .WithResources(); ``` @@ -209,8 +209,8 @@ Register subscription handlers when building the server: ```csharp builder.Services.AddMcpServer() // Subscriptions require stateful mode because the server pushes change notifications - // to clients. Set Stateless = false explicitly for forward compatibility. - .WithHttpTransport(o => o.Stateless = false) + // to clients. Set SessionMode = HttpServerSessionMode.Stateful since sessions are required. + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index a7a8ed2e4..fa9e1cd54 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -112,7 +112,7 @@ server.RegisterNotificationHandler( [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 3dcbd44c5..7a1f90763 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -129,7 +129,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 899fd1864..c393d3de0 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -7,9 +7,9 @@ uid: stateless # Stateless and stateful mode -The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). +The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to **. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). -When sessions are enabled (`Stateless = false`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). +When sessions are enabled (`SessionMode = HttpServerSessionMode.Stateful`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). [Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http @@ -20,29 +20,36 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a - Do you need to support clients that only speak the [legacy SSE transport](#legacy-sse-transport)? → **Use stateful** with (disabled by default due to [backpressure concerns](#request-backpressure)). - Does your server manage per-client state that concurrent agents must not share (isolated environments, parallel workspaces)? → **Use stateful.** - Are you debugging a typically-stdio server over HTTP and want editors to be able to reset state by reconnecting? → **Use stateful.** -- Otherwise → **Use stateless** (`options.Stateless = true`). +- Does your server need sessions for existing clients but must also serve `2026-07-28` clients natively on the same endpoint? → **Use [hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only)** (`options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients`). +- Otherwise → **Use stateless** (`options.SessionMode = HttpServerSessionMode.Stateless`). > [!NOTE] -> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful, but not because the `2025-11-25` (and older) protocol revisions ever required a server to use the `Mcp-Session-Id` header. They didn't. The original SSE transport could only operate statefully, and keeping Streamable HTTP stateful by default let server-to-client requests (elicitation, sampling, roots) keep working on `2025-11-25` the way they always had. A client was required to echo a server-assigned `Mcp-Session-Id` on later requests, but whether to assign one was always the server's choice. The `2026-07-28` protocol revision removes the header (SEP-2567) and the `initialize` handshake (SEP-2575) from the wire format entirely, and server-to-client requests now run through [MRTR](xref:mrtr), so the SDK now defaults to `true` to match the new wire format. You can still opt back into sessions with `Stateless = false` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). +> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful, but not because the `2025-11-25` (and older) protocol revisions ever required a server to use the `Mcp-Session-Id` header. They didn't. The original SSE transport could only operate statefully, and keeping Streamable HTTP stateful by default let server-to-client requests (elicitation, sampling, roots) keep working on `2025-11-25` the way they always had. A client was required to echo a server-assigned `Mcp-Session-Id` on later requests, but whether to assign one was always the server's choice. The `2026-07-28` protocol revision removes the header (SEP-2567) and the `initialize` handshake (SEP-2575) from the wire format entirely, and server-to-client requests now run through [MRTR](xref:mrtr), so the SDK now defaults to to match the new wire format. You can still opt back into sessions with `SessionMode = HttpServerSessionMode.Stateful` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) when both peers support `2026-07-28`. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `SessionMode` property is the single most important setting for forward-proofing your MCP server. The default is now (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) when both peers support `2026-07-28`. We recommend every server set `SessionMode` explicitly rather than relying on the default: -- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`HttpServerSessionMode.Stateless`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this configuration lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). Keep a session if you need server-to-client requests against clients that do not support `2026-07-28`. Note that with `Stateless = false`, a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. +- **`HttpServerSessionMode.Stateful`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). Keep a session if you need server-to-client requests against clients that do not support `2026-07-28`. Note that in this mode a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. + +- **`HttpServerSessionMode.StatefulForInitializeClients`** — the migration mode. `initialize`-handshake clients get full sessions while `2026-07-28` and later clients are served statelessly on the same endpoint, so you can adopt the new revision progressively instead of waiting for every client to migrate. See [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only). > [!TIP] -> If you're not sure which to pick, leave the default (`Stateless = true`). You can switch to `Stateless = false` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. +> If you're not sure which to pick, leave the default (`HttpServerSessionMode.Stateless`). You can switch to `HttpServerSessionMode.Stateful` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. + + +> [!NOTE] +> The `bool` property remains a convenient shorthand for the two most common modes: assigning `true` selects `Stateless` and assigning `false` selects `Stateful`, while reading it returns `true` only for `Stateless`. Use `SessionMode` when you need `StatefulForInitializeClients`. Both properties update the same underlying value, so the last assignment wins. ### The 2026-07-28 protocol revision -The `2026-07-28` protocol revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). +The `2026-07-28` protocol revision goes further than stateless mode: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). -**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP `POST` that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints aren't mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request. +**Server side.** With `SessionMode = HttpServerSessionMode.Stateless` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP `POST` that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints aren't mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `SessionMode = HttpServerSessionMode.Stateful`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. `SessionMode = HttpServerSessionMode.StatefulForInitializeClients` opts out of that downgrade and serves the `2026-07-28` request statelessly instead — see [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only). If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request, in every mode. **Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients. @@ -80,7 +87,7 @@ With the default **Server-side migration.** If you previously relied on `/sse` being mapped automatically, you now need `EnableLegacySse = true` (suppressing the `MCP9004` warning) to keep serving those endpoints. The recommended path is to migrate all clients to Streamable HTTP and then remove `EnableLegacySse`. -**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `Stateless = false`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `Stateless = true`. +**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `SessionMode = HttpServerSessionMode.Stateful`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `SessionMode = HttpServerSessionMode.Stateless`. ## Stateless mode (recommended) @@ -89,12 +96,14 @@ Stateless mode is the recommended default for HTTP-based MCP servers. When enabl ### Enabling stateless mode ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -105,7 +114,7 @@ app.Run(); ### What stateless mode changes -When is `true`: +When is : - is `null`, and the `Mcp-Session-Id` header is not sent or expected - Each HTTP request creates a fresh server context — no state carries over between requests @@ -145,7 +154,7 @@ This means servers that need user confirmation ([elicitation](xref:elicitation)) ## Stateful mode (sessions) -When is `false`, the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: +When is , the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: - Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream - [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the `GET` stream @@ -166,6 +175,42 @@ Use stateful mode when your server needs one or more of: The [deployment considerations](#deployment-considerations) section lists real concerns for production, internet-facing services — but many MCP servers don't run in that context. For single-instance servers, internal tools, and dev/test clusters, session affinity and memory overhead are less of a concern, and sessions provide the richest feature set. +## Hybrid mode (sessions for initialize clients only) + + serves both eras on a single endpoint: clients that send the `initialize` handshake (`2025-11-25` and earlier) get a full stateful session, while clients using `2026-07-28` and later are served statelessly, per request. It exists so an existing stateful server can adopt the new protocol revision progressively instead of waiting for every client to migrate first. + +```csharp +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + }) + .WithTools(); +``` + +### What each client sees + +| Client | Behavior | +|---|---| +| `2025-11-25` and earlier (sends `initialize`) | Full stateful session, `Mcp-Session-Id` issued and echoed, `GET` and `DELETE` available, server-to-client requests supported | +| `2026-07-28` and later (sends `server/discover`) | Served per request with no session ID minted or echoed; `GET` and `DELETE` return `405 Method Not Allowed`; no downgrade to `initialize` | + +Without hybrid mode, `HttpServerSessionMode.Stateful` refuses a `2026-07-28` request with `-32022 UnsupportedProtocolVersion` so that dual-path clients fall back to `initialize`. Hybrid mode removes that refusal for clients that don't want (or can't perform) the downgrade. + +### What stays unavailable to `2026-07-28` clients + +A `2026-07-28` request has no session even on a hybrid endpoint, so all of the [stateless mode restrictions](#what-stateless-mode-changes) still apply to it: no [unsolicited notifications](#how-streamable-http-delivers-messages), no resource subscriptions, no server-initiated ping, and no per-client isolation. Use [MRTR](xref:mrtr) for elicitation (and the deprecated sampling and roots) on that half of the endpoint. Legacy sessions on the same endpoint keep all of those features. + +### Lifetimes + +Because the effective mode is decided per request, DI and callback lifetimes follow the request rather than the endpoint: + +- `2026-07-28` requests resolve services from `HttpContext.RequestServices` with request scoping disabled, exactly like [stateless HTTP](#stateless-http). +- `initialize`-handshake sessions resolve services from the application provider and scope each request, exactly like [stateful HTTP](#stateful-http). +- runs once per session for `initialize`-handshake clients and once per HTTP request for `2026-07-28` clients. + +Stateful-only options (`IdleTimeout`, `EventStreamStore`, `SessionMigrationHandler`, and so on) continue to govern the session half of the endpoint and are ignored for `2026-07-28` requests. + ## Comparison | Consideration | Stateless | Stateful | @@ -183,6 +228,9 @@ The [deployment considerations](#deployment-considerations) section lists real c | **State reset on reconnect** | No concept of reconnection — every request stands alone | Client reconnection starts a new session with a clean slate | | **[Tasks](xref:tasks)** | Supported — shared task store, no per-session isolation | Supported — task store scoped per session | +> [!NOTE] +> [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only) doesn't add a third column: each request follows the **Stateless** column when the client negotiated `2026-07-28` and the **Stateful** column when the client used the `initialize` handshake. + ## Transports and sessions ### Streamable HTTP @@ -390,7 +438,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Recommended for servers that don't need sessions. - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; // --- Options below only apply to stateful (non-stateless) mode --- @@ -417,7 +465,8 @@ builder.Services.AddMcpServer() | Property | Type | Default | Description | |----------|------|---------|-------------| -| | `bool` | `true` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests on the legacy protocol. Required by the `2026-07-28` protocol revision. | +| | | `Stateless` | Selects how the server tracks state between requests: `Stateless` (no sessions), `Stateful` (sessions for every client, `2026-07-28` refused), or `StatefulForInitializeClients` ([hybrid](#hybrid-mode-sessions-for-initialize-clients-only)). | +| | `bool` | `true` | Convenience proxy over `SessionMode`: `true` maps to `Stateless`, `false` maps to `Stateful`, and hybrid mode reads as `false`. Use `SessionMode` to select hybrid mode. | | | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. | | | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. | | | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all `2026-07-28` requests), this runs on every HTTP request. | @@ -426,7 +475,7 @@ builder.Services.AddMcpServer() | | `ISseEventStreamStore?` | `null` | _Stateful only (`MCP9006`)._ Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | | | `bool` | `false` | _Stateful only (`MCP9006`)._ Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | -The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served without a session (every `2026-07-28` request, plus every request on a server with `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. +The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served without a session (every `2026-07-28` request, plus every request on a server with `SessionMode = HttpServerSessionMode.Stateless`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. ### ConfigureSessionOptions @@ -457,7 +506,7 @@ In **stateless mode**, `ConfigureSessionOptions` is called on **every HTTP reque builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => { // This runs on every request in stateless mode, so you can use the @@ -746,7 +795,7 @@ In stateless mode, each HTTP request is its own "session", so `mcp.server.sessio The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](#request-backpressure). To enable them, set to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9004`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. > [!NOTE] -> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests. +> Setting `EnableLegacySse = true` while `SessionMode = HttpServerSessionMode.Stateless` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests. ### How SSE sessions work @@ -778,7 +827,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Session migration is a stateful-mode feature. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.SessionMigrationHandler = new MySessionMigrationHandler(); }); ``` @@ -806,7 +855,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Session resumability is a stateful-mode feature. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.EventStreamStore = new MyEventStreamStore(); }); ``` diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index df8887cb2..3a68d619b 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -39,7 +39,7 @@ Register the tool type when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithTools(); ``` diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 68331930e..bb4e155f2 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -168,13 +168,16 @@ await using var client = await McpClient.ResumeSessionAsync(transport, new Resum Use the `ModelContextProtocol.AspNetCore` package to host an MCP server over HTTP. The method maps the Streamable HTTP endpoint at the specified route (root by default). ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // Recommended for servers that don't need server-to-client requests. - options.Stateless = true; + // Stateless mode is the default and recommended for + // servers that don't need server-to-client requests. + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -183,7 +186,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `SessionMode = HttpServerSessionMode.Stateful` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). #### Host name validation @@ -297,20 +300,22 @@ SSE-specific configuration options: #### SSE server (ASP.NET Core) -The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** and is marked `[Obsolete]` (diagnostic `MCP9004`). SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. +The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** and is marked `[Obsolete]` (diagnostic `MCP9004`). SSE always requires stateful mode; legacy SSE endpoints are never mapped when `SessionMode = HttpServerSessionMode.Stateless`. **Why SSE is disabled by default.** The SSE transport separates request and response channels: clients `POST` JSON-RPC messages to `/message` and receive all responses through a long-lived `GET` SSE stream on `/sse`. Because the `POST` endpoint returns `202 Accepted` immediately — before the handler even runs — there is **no HTTP-level backpressure** on handler concurrency. A client (or attacker) can flood the server with tool calls without waiting for prior requests to complete. In contrast, Streamable HTTP holds each `POST` response open until the handler finishes, providing natural backpressure. For a detailed comparison and mitigations if you must use SSE, see [Request backpressure](xref:stateless#request-backpressure). To enable legacy SSE, set `EnableLegacySse` to `true`: ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { // SSE requires stateful mode; opt in explicitly because stateless mode is the default. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; #pragma warning disable MCP9004 // EnableLegacySse is obsolete // Enable legacy SSE endpoints for clients that don't support Streamable HTTP. diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 1ad8206be..577334246 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -44,5 +44,5 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | | `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) for more information. | -| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | +| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when the request is served with a session. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `SessionMode` to `HttpServerSessionMode.Stateless`. These options remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | | `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the authorization-response state or RFC 9207 issuer. State and issuer validation are skipped when these APIs are used. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for response-bound, issuer-aware authorization flows. | diff --git a/samples/AspNetCoreMcpPerSessionTools/Program.cs b/samples/AspNetCoreMcpPerSessionTools/Program.cs index 983d296f2..68c37e134 100644 --- a/samples/AspNetCoreMcpPerSessionTools/Program.cs +++ b/samples/AspNetCoreMcpPerSessionTools/Program.cs @@ -1,4 +1,5 @@ using AspNetCoreMcpPerSessionTools.Tools; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.Collections.Concurrent; using System.Reflection; @@ -14,8 +15,8 @@ .WithHttpTransport(options => { // This sample demonstrates per-session tool filtering, which requires stateful mode. - // Set Stateless = false explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since sessions are required. + options.SessionMode = HttpServerSessionMode.Stateful; // Configure per-session options to filter tools based on route category options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => diff --git a/samples/AspNetCoreMcpPerSessionTools/README.md b/samples/AspNetCoreMcpPerSessionTools/README.md index e0d968042..3d37568ed 100644 --- a/samples/AspNetCoreMcpPerSessionTools/README.md +++ b/samples/AspNetCoreMcpPerSessionTools/README.md @@ -65,9 +65,9 @@ The key technique is using `ConfigureSessionOptions` to modify the tool collecti ```csharp .WithHttpTransport(options => { - // Per-session tool filtering requires stateful mode. Set Stateless = false - // explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since per-session + // tool filtering requires sessions. + options.SessionMode = HttpServerSessionMode.Stateful; options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => { var toolCategory = GetToolCategoryFromRoute(httpContext); diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index 3441083bb..a69f499e4 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -1,4 +1,5 @@ using Azure.Monitor.OpenTelemetry.AspNetCore; +using ModelContextProtocol.AspNetCore; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -26,10 +27,10 @@ // Note: This sample uses SampleLlmTool which calls server.AsSamplingChatClient() to send // a server-to-client sampling request. This requires stateful (session-based) mode. Set -// Stateless = false explicitly for forward compatibility in case the default changes. +// SessionMode = HttpServerSessionMode.Stateful since sessions are required for sampling. // See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for details. builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = false) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) .WithTools() .WithTools() .WithTools() diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index f8c975212..abccbac88 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -3,6 +3,7 @@ using EverythingServer.Resources; using EverythingServer.Tools; using Microsoft.Extensions.AI; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -58,8 +59,8 @@ .WithHttpTransport(options => { // This sample uses subscriptions, SampleLlmTool (sampling), and RunSessionHandler. - // Set Stateless = false explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since sessions are required for sampling. + options.SessionMode = HttpServerSessionMode.Stateful; // Add a RunSessionHandler to remove all subscriptions for the session when it ends #pragma warning disable MCPEXP002 // RunSessionHandler is experimental diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index f539e73bb..59ad3522d 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -2,6 +2,7 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.AspNetCore; using ProtectedMcpServer.Tools; using System.Net.Http.Headers; using System.Security.Claims; @@ -90,10 +91,10 @@ .WithTools() .WithHttpTransport(options => { - // Stateless mode is recommended for servers that don't need server-to-client - // requests like sampling or elicitation. It enables horizontal scaling without - // session affinity and works with clients that don't send Mcp-Session-Id. - options.Stateless = true; + // Stateless mode is the default and recommended for servers that don't need 2025-11-25 + // protocol revision server-to-client requests like sampling or elicitation. Stateless model enables + // horizontal scaling without session affinity and works with clients that don't send Mcp-Session-Id. + options.SessionMode = HttpServerSessionMode.Stateless; }); // Configure HttpClientFactory for weather.gov API diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 6ecab2b3c..217ff97ea 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -44,7 +44,7 @@ internal static class Obsoletions public const string DeprecatedLogging_Message = "The Logging feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information."; public const string LegacyStatefulHttp_DiagnosticId = "MCP9006"; - public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; + public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for 2025-11-25 protocol revision clients and earlier. Set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; public const string AuthorizationRedirectDelegate_DiagnosticId = "MCP9007"; diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs b/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs new file mode 100644 index 000000000..829b7e83b --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs @@ -0,0 +1,64 @@ +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Specifies how the Streamable HTTP transport tracks state between requests. +/// +/// +/// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions +/// (SEP-2567 removed Mcp-Session-Id, and SEP-2575 removed the initialize handshake), so requests +/// using that revision or later can only ever be served statelessly. This enumeration allows specification for +/// how the server reconciles that requirement with clients that still rely on the initialize handshake. +/// +public enum HttpServerSessionMode +{ + /// + /// The server never tracks state between requests, allowing for load balancing without session affinity. + /// + /// + /// is , the + /// Mcp-Session-Id header is unused, and + /// are invoked once per request, and the + /// GET, DELETE, and /sse endpoints are unavailable. Unsolicited server-to-client messages and all + /// server-to-client requests are unsupported because any response might arrive at another ASP.NET Core + /// application process. Client sampling, elicitation, and roots capabilities are disabled because the + /// server cannot make requests; use Multi Round-Trip Requests (MRTR) + /// instead. + /// + Stateless, + + /// + /// The server tracks a long-lived session for every client, which requires session affinity. + /// + /// + /// Requests using the 2026-07-28 or later protocol revision are refused with a + /// -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to the + /// initialize handshake and obtains the session the server was configured to provide. Use + /// to serve those clients natively instead of forcing a downgrade. + /// + Stateful, + + /// + /// The server tracks a long-lived session for clients that use the initialize handshake and serves + /// clients using the 2026-07-28 or later protocol revision statelessly on the same endpoint. + /// + /// + /// + /// This hybrid mode allows an application to adopt the latest protocol revision progressively rather than + /// waiting for every client to migrate. Clients using the 2025-11-25 or earlier revisions get a full + /// stateful session with an Mcp-Session-Id and continue to use the GET and DELETE endpoints, while + /// clients using the 2026-07-28 or later revisions are served per request with no session ID minted + /// or echoed, and receive 405 Method Not Allowed for GET and DELETE, exactly as in + /// mode. + /// + /// + /// Because a 2026-07-28 request has no session, the session-only features listed on + /// remain unavailable to those clients even though other clients on the same + /// endpoint have sessions. is invoked once + /// per session for initialize-handshake clients and once per request for 2026-07-28 and later + /// clients. + /// + /// + StatefulForInitializeClients, +} diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 024772240..7ecfc0748 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -18,9 +18,11 @@ public class HttpServerTransportOptions /// with access to the of the request that initiated the session. /// /// - /// In stateful mode (the default), this callback is invoked once per session when the client sends the - /// initialize request. In mode, it is invoked on every HTTP request - /// because each request creates a fresh server context. + /// In stateful mode, this callback is invoked once per session when the client sends the + /// initialize request. In mode, it is invoked on + /// every HTTP request because each request creates a fresh server context. In + /// mode, both apply: once per session for + /// initialize-handshake clients and once per request for 2026-07-28 and later clients. /// public Func? ConfigureSessionOptions { get; set; } @@ -39,12 +41,56 @@ public class HttpServerTransportOptions /// of the initializing request with fewer known issues. /// /// + /// In mode, this callback is invoked once per session. In + /// mode, it is invoked once per HTTP request. In + /// mode, both apply: once per session for + /// initialize-handshake clients and once per request for 2026-07-28 and later clients. + /// + /// /// This API is experimental and may be removed or change signatures in a future release. /// /// [System.Diagnostics.CodeAnalysis.Experimental(Experimentals.RunSessionHandler_DiagnosticId, UrlFormat = Experimentals.RunSessionHandler_Url)] public Func? RunSessionHandler { get; set; } + /// + /// Gets or sets a value that indicates how the server tracks state between requests. + /// + /// + /// One of the values. The default is + /// as of the 2026-07-28 protocol revision (SEP-2567). + /// + /// + /// + /// doesn't track state between requests, allowing for load + /// balancing without session affinity. will be null, the + /// "MCP-Session-Id" header will not be used, the will be called once for + /// each request, and the GET, DELETE, and "/sse" endpoints will be disabled. Unsolicited server-to-client + /// messages and all server-to-client requests are also unsupported, because any responses might arrive at + /// another ASP.NET Core application process. Client sampling, elicitation, and roots capabilities are also + /// disabled, because the server cannot make requests. + /// + /// + /// tracks a session for every client, which requires session + /// affinity. Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports + /// sessions: the revision removed Mcp-Session-Id (SEP-2567), so such a request is refused with a + /// -32022 UnsupportedProtocolVersion error, and a dual-path client downgrades to the + /// initialize handshake and obtains the session the server was configured to provide. + /// + /// + /// avoids that downgrade by serving + /// 2026-07-28 and later requests statelessly on the same endpoint while initialize-handshake + /// clients still get full sessions. Session-only features remain unavailable to the stateless half of the + /// endpoint; use MRTR for + /// elicitation there. + /// + /// + /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored + /// in every mode; the server must not mint or echo session IDs for those revisions. + /// + /// + public HttpServerSessionMode SessionMode { get; set; } = HttpServerSessionMode.Stateless; + /// /// Gets or sets a value that indicates whether the server runs in a stateless mode that doesn't track state between requests, /// allowing for load balancing without session affinity. @@ -55,22 +101,18 @@ public class HttpServerTransportOptions /// set to only when you need to support legacy clients that rely on session affinity. /// /// - /// If , will be null, and the "MCP-Session-Id" header will not be used, - /// the will be called once for each request, and the "/sse" endpoint will be disabled. - /// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses - /// might arrive at another ASP.NET Core application process. - /// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests. - /// - /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: - /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served - /// when this property is . When it is , such a request is - /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to - /// the initialize handshake and obtains the session the server was configured to provide. - /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored; - /// the server must not mint or echo session IDs for those revisions. - /// + /// This property is a convenience proxy over . Reading it returns + /// only when is , + /// so reads as . + /// Assigning selects and assigning + /// selects . Because both properties + /// update the same underlying value, the last assignment wins when both are configured. /// - public bool Stateless { get; set; } = true; + public bool Stateless + { + get => SessionMode is HttpServerSessionMode.Stateless; + set => SessionMode = value ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; + } /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) @@ -94,8 +136,9 @@ public class HttpServerTransportOptions /// built-in backpressure. /// /// - /// Setting this to while is also - /// throws an at startup, because SSE requires in-memory session state. + /// Setting this to while is + /// throws an at + /// startup, because SSE requires in-memory session state. /// /// /// This property can also be enabled via the ModelContextProtocol.AspNetCore.EnableLegacySse diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index b11fe81cd..de11d8a2a 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -36,7 +36,7 @@ public IdleTrackingBackgroundService( public override Task StartAsync(CancellationToken cancellationToken) { // In stateless mode there are no sessions to track, so skip starting the periodic timer entirely. - if (_options.Value.Stateless) + if (_options.Value.SessionMode is HttpServerSessionMode.Stateless) { return Task.CompletedTask; } diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index c95a5a835..e5fc3fa4d 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -32,7 +32,7 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo var options = streamableHttpHandler.HttpServerTransportOptions; #pragma warning disable MCP9004 // EnableLegacySse - reading the obsolete property to check if SSE is enabled - if (options.Stateless && options.EnableLegacySse) + if (options.SessionMode is HttpServerSessionMode.Stateless && options.EnableLegacySse) { throw new InvalidOperationException( "Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " + @@ -50,10 +50,12 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); - if (!options.Stateless) + if (options.SessionMode is not HttpServerSessionMode.Stateless) { // The GET endpoint is not mapped in Stateless mode since there's no way to send unsolicited messages. // Resuming streams via GET is currently not supported in Stateless mode. + // In StatefulForInitializeClients mode both endpoints stay mapped for initialize-handshake clients; + // the handlers reject 2026-07-28 and later requests with 405 Method Not Allowed. streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])); diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index f0b0b1a12..50c20a792 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -38,8 +38,8 @@ internal sealed class StreamableHttpHandler( /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and - /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path - /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version. + /// later). Used when refusing a 2026-07-28 request on a fully stateful server so a dual-path client falls + /// back to the initialize handshake instead of retrying the 2026-07-28 version. /// private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; @@ -53,6 +53,14 @@ internal sealed class StreamableHttpHandler( public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; + /// + /// Returns when no request served by this endpoint can have a session. In + /// mode this is + /// even though individual 2026-07-28 and later requests are still served statelessly, because the + /// endpoint as a whole still tracks sessions for initialize-handshake clients. + /// + private bool IsStatelessOnly => HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateless; + public async Task HandlePostRequestAsync(HttpContext context) { // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. @@ -100,7 +108,7 @@ await WriteJsonRpcErrorAsync(context, // Validated after the body parse (rather than first) so the rejection can echo the request's // JSON-RPC id: every error response for a parseable request MUST carry its id. var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId); return; @@ -191,7 +199,7 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -238,7 +246,7 @@ await WriteJsonRpcErrorAsync(context, private async Task HandleResumedStreamAsync(HttpContext context, StreamableHttpSession session, string lastEventId) { - if (HttpServerTransportOptions.Stateless) + if (IsStatelessOnly) { await WriteJsonRpcErrorAsync(context, "Bad Request: The Last-Event-ID header is not supported in stateless mode.", @@ -310,7 +318,7 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -354,7 +362,7 @@ await WriteJsonRpcErrorAsync(context, { await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required for GET and DELETE requests when the server is using sessions. " + - "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " + + "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest, requestId: requestId); return null; @@ -435,17 +443,18 @@ await WriteJsonRpcErrorAsync(context, // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: if (RequiresPerRequestMetadataProtocol(context)) { - if (!HttpServerTransportOptions.Stateless) + if (HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateful) { - // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 - // revision cannot provide. Refuse it so a dual-path client falls back to the - // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). + // The author explicitly opted into sessions for every client, which the 2026-07-28 revision + // cannot provide. Refuse it so a dual-path client falls back to the initialize handshake and + // gets the session it asked for (SEP-2575 fallback semantics). StatefulForInitializeClients + // opts out of that downgrade and serves these requests statelessly instead. await WriteUnsupportedProtocolVersionErrorAsync(context, requestId); return null; } - // The default (stateless) HTTP transport serves these requests natively. - return await StartNewSessionAsync(context); + // Stateless and StatefulForInitializeClients both serve these requests natively, without a session. + return await StartNewSessionAsync(context, serveStatelessly: true); } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); @@ -453,20 +462,20 @@ await WriteJsonRpcErrorAsync(context, { // In stateful mode, only allow creating new sessions for initialize requests. // In stateless mode, every request is independent, so we always create a new session. - if (!HttpServerTransportOptions.Stateless && !AllowNewSessionForNonInitializeRequests + if (!IsStatelessOnly && !AllowNewSessionForNonInitializeRequests && message is not JsonRpcRequest { Method: RequestMethods.Initialize }) { await WriteJsonRpcErrorAsync(context, "Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " + - "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " + + "or enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless if your server doesn't need sessions. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest, requestId: requestId); return null; } - return await StartNewSessionAsync(context); + return await StartNewSessionAsync(context, serveStatelessly: IsStatelessOnly); } - else if (HttpServerTransportOptions.Stateless) + else if (IsStatelessOnly) { // In stateless mode, we should not be getting existing sessions via sessionId // This path should not be reached in stateless mode @@ -491,12 +500,12 @@ private static bool RequiresPerRequestMetadataProtocol(HttpContext context) return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader); } - private async ValueTask StartNewSessionAsync(HttpContext context) + private async ValueTask StartNewSessionAsync(HttpContext context, bool serveStatelessly) { string sessionId; StreamableHttpServerTransport transport; - if (!HttpServerTransportOptions.Stateless) + if (!serveStatelessly) { sessionId = MakeNewSessionId(); #pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. @@ -525,23 +534,24 @@ private async ValueTask StartNewSessionAsync(HttpContext }; } - return await CreateSessionAsync(context, transport, sessionId); + return await CreateSessionAsync(context, transport, sessionId, serveStatelessly); } private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, string sessionId, + bool serveStatelessly, Action? configureOptions = null) { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + if (serveStatelessly || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (HttpServerTransportOptions.Stateless) + if (serveStatelessly) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -589,7 +599,7 @@ private async ValueTask MigrateSessionAsync( context.Response.Headers[McpSessionIdHeaderName] = sessionId; - return await CreateSessionAsync(context, transport, sessionId, options => + return await CreateSessionAsync(context, transport, sessionId, serveStatelessly: false, options => { options.KnownClientInfo = initializeParams.ClientInfo; options.KnownClientCapabilities = initializeParams.Capabilities; @@ -916,11 +926,13 @@ metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue && } /// - /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that - /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to - /// sessions; we return with a supported-versions list - /// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake - /// (SEP-2575). + /// Refuses a 2026-07-28 (or later) request on a fully stateful server + /// (). Starting with that revision, Streamable HTTP no longer + /// has sessions (SEP-2567), so it cannot honor the author's opt-in to sessions; we return + /// with a supported-versions list that excludes + /// 2026-07-28 and later. A dual-path client then falls back to the initialize handshake (SEP-2575). + /// serves the request statelessly instead + /// of refusing it. /// private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default) { @@ -928,8 +940,8 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + - "Use the initialize handshake with a protocol version that still supports sessions instead.", + Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateful). " + + "Use the initialize handshake with a protocol version that still supports sessions instead, or set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.StatefulForInitializeClients to serve this version statelessly.", Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs new file mode 100644 index 000000000..ae5e19fe2 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs @@ -0,0 +1,68 @@ +namespace ModelContextProtocol.AspNetCore.Tests; + +public class HttpServerTransportOptionsTests +{ + [Fact] + public void SessionMode_DefaultsToStateless() + { + var options = new HttpServerTransportOptions(); + + Assert.Equal(HttpServerSessionMode.Stateless, options.SessionMode); + Assert.True(options.Stateless); + } + + [Theory] + [InlineData(true, HttpServerSessionMode.Stateless)] + [InlineData(false, HttpServerSessionMode.Stateful)] + public void SettingStateless_SelectsEquivalentSessionMode(bool stateless, HttpServerSessionMode expected) + { + var options = new HttpServerTransportOptions { Stateless = stateless }; + + Assert.Equal(stateless, options.Stateless); + Assert.Equal(expected, options.SessionMode); + } + + [Theory] + [InlineData(HttpServerSessionMode.Stateless, true)] + [InlineData(HttpServerSessionMode.Stateful, false)] + public void ReadingStateless_ReflectsSessionMode(HttpServerSessionMode sessionMode, bool expected) + { + var options = new HttpServerTransportOptions { SessionMode = sessionMode }; + + Assert.Equal(expected, options.Stateless); + } + + [Fact] + public void ReadingStateless_ReturnsFalseForHybridMode() + { + var options = new HttpServerTransportOptions + { + SessionMode = HttpServerSessionMode.StatefulForInitializeClients, + }; + + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.StatefulForInitializeClients, options.SessionMode); + } + + [Fact] + public void AssigningBothProperties_DoesNotThrow_AndLastAssignmentWins() + { + var options = new HttpServerTransportOptions(); + + options.Stateless = false; + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.Stateful, options.SessionMode); + + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.StatefulForInitializeClients, options.SessionMode); + + options.Stateless = true; + Assert.True(options.Stateless); + Assert.Equal(HttpServerSessionMode.Stateless, options.SessionMode); + + options.SessionMode = HttpServerSessionMode.Stateful; + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.Stateful, options.SessionMode); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index ab9d04b70..365cf281d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -24,10 +24,10 @@ private async Task StartAsync(bool stateless = false) options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHttpHandlerTests), Version = "1" }; }).WithHttpTransport(options => { - // Stateless = false maps the GET/DELETE endpoints and opts the author into sessions. Starting with + // SessionMode = HttpServerSessionMode.Stateful maps the GET/DELETE endpoints and opts the author into sessions. Starting with // the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so such a request is - // refused on a session-enabled server. Stateless = true (the default) serves them natively. - options.Stateless = stateless; + // refused on a session-enabled server. SessionMode = HttpServerSessionMode.Stateless (the default) serves them natively. + options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; }); _app = Builder.Build(); @@ -69,7 +69,7 @@ public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567), - // so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that + // so the server cannot honor it when configured with sessions (SessionMode = HttpServerSessionMode.Stateful). The server refuses that // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back // to the initialize handshake. await StartAsync(stateless: false); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs new file mode 100644 index 000000000..01a4b26cf --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs @@ -0,0 +1,344 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// End-to-end coverage for : a single endpoint +/// that serves initialize-handshake clients with full stateful sessions while serving 2026-07-28 +/// and later clients statelessly, without forcing them to downgrade +/// (). +/// +[McpServerToolType] +public class July2026ProtocolHybridSessionModeTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + private int _configureSessionOptionsCount; + private int _runSessionHandlerCount; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [McpServerTool(Name = "greet")] + public static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!"; + + [McpServerTool(Name = "greet_via_elicit")] + public static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) + { + // Server to client requests only work over a stateful session, so this proves the initialize-handshake + // half of a hybrid endpoint keeps its session even though the endpoint also serves stateless requests. + var elicitResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new(), + }, cancellationToken); + + var name = elicitResult.Content?.TryGetValue("answer", out var answer) == true + ? answer.GetString() + : "stranger"; + + return $"Hello, {name}!"; + } + + [McpServerTool(Name = "scope_state")] + public static string ScopeState(ScopedService scopedService) => scopedService.State ?? ""; + + private async Task StartHybridServerAsync(bool trackRunSessionHandler = false) + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHybridSessionModeTests), Version = "1" }; + }) + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => + { + Interlocked.Increment(ref _configureSessionOptionsCount); + return Task.CompletedTask; + }; + + if (trackRunSessionHandler) + { +#pragma warning disable MCPEXP002 // RunSessionHandler is experimental. + options.RunSessionHandler = async (httpContext, server, cancellationToken) => + { + Interlocked.Increment(ref _runSessionHandlerCount); + await server.RunAsync(cancellationToken); + }; +#pragma warning restore MCPEXP002 + } + }) + .WithTools(); + + Builder.Services.AddScoped(); + + _app = Builder.Build(); + + _app.Use(next => context => + { + context.RequestServices.GetRequiredService().State = "From request middleware!"; + return next(context); + }); + + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private Task ConnectClientAsync(string? protocolVersion = null, Action? configureClient = null) + { + var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:5000/"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + // A null ProtocolVersion prefers 2026-07-28 and probes with server/discover before considering a + // fallback to the initialize handshake. Pinning an older version forces the initialize handshake. + var clientOptions = new McpClientOptions { ProtocolVersion = protocolVersion }; + configureClient?.Invoke(clientOptions); + return McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ModernAndLegacyClients_ShareOneEndpoint_AndModernDoesNotDowngrade() + { + await StartHybridServerAsync(); + + await using var modernClient = await ConnectClientAsync(); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // The whole point of the hybrid mode: the default client keeps 2026-07-28 instead of downgrading. + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, modernClient.NegotiatedProtocolVersion); + Assert.Null(modernClient.SessionId); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, legacyClient.NegotiatedProtocolVersion); + Assert.False(string.IsNullOrEmpty(legacyClient.SessionId)); + + // Both halves of the endpoint remain usable while the other is connected. + var modernResult = await modernClient.CallToolAsync("greet", + new Dictionary { ["name"] = "Modern" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Hello, Modern!", Assert.IsType(Assert.Single(modernResult.Content)).Text); + + var legacyResult = await legacyClient.CallToolAsync("greet", + new Dictionary { ["name"] = "Legacy" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Hello, Legacy!", Assert.IsType(Assert.Single(legacyResult.Content)).Text); + } + + [Fact] + public async Task LegacyClient_OnHybridServer_StillSupportsServerToClientElicitation() + { + await StartHybridServerAsync(); + + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion, options => + { + options.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(), + }, + }); + }); + + var result = await legacyClient.CallToolAsync("greet_via_elicit", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError is not true); + Assert.Equal("Hello, Bob!", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task ModernRequests_UseRequestScopedServices_WhileLegacySessionsUseApplicationServices() + { + await StartHybridServerAsync(); + + await using var modernClient = await ConnectClientAsync(); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // Stateless requests resolve services from HttpContext.RequestServices, so the tool observes the state + // that the ASP.NET Core middleware set on the request-scoped service. + var modernResult = await modernClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("From request middleware!", Assert.IsType(Assert.Single(modernResult.Content)).Text); + + // Stateful sessions outlive the HTTP request, so they scope requests off the application services + // instead and never see the middleware's request-scoped state. + var legacyResult = await legacyClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("", Assert.IsType(Assert.Single(legacyResult.Content)).Text); + } + + [Fact] + public async Task ConfigureSessionOptions_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients() + { + await StartHybridServerAsync(); + + var beforeLegacyConnect = Volatile.Read(ref _configureSessionOptionsCount); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // The initialize request creates the session; notifications/initialized reuses it. + Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyConnect); + + var beforeLegacyCalls = Volatile.Read(ref _configureSessionOptionsCount); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + + // Subsequent requests reuse the session, so the callback does not run again. + Assert.Equal(0, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyCalls); + + await using var modernClient = await ConnectClientAsync(); + + var beforeModernCall = Volatile.Read(ref _configureSessionOptionsCount); + await modernClient.CallToolAsync("greet", new Dictionary { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken); + + // Each 2026-07-28 POST creates a fresh per-request server, so the callback runs again. + Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeModernCall); + } + + [Fact] + public async Task RunSessionHandler_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients() + { + await StartHybridServerAsync(trackRunSessionHandler: true); + + var beforeLegacyConnect = Volatile.Read(ref _runSessionHandlerCount); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyConnect); + + var beforeLegacyCalls = Volatile.Read(ref _runSessionHandlerCount); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(0, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyCalls); + + var beforeModernConnect = Volatile.Read(ref _runSessionHandlerCount); + await using var modernClient = await ConnectClientAsync(); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernConnect); + + var beforeModernCall = Volatile.Read(ref _runSessionHandlerCount); + await modernClient.CallToolAsync("greet", new Dictionary { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernCall); + } + + [Fact] + public async Task ModernPost_DoesNotMintSessionId_WhileLegacyInitializeDoes() + { + await StartHybridServerAsync(); + + using var modernResponse = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest, mcpMethod: "server/discover"); + Assert.Equal(HttpStatusCode.OK, modernResponse.StatusCode); + Assert.False(modernResponse.Headers.Contains("Mcp-Session-Id"), "2026-07-28 responses must not include Mcp-Session-Id."); + + using var legacyResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest); + Assert.Equal(HttpStatusCode.OK, legacyResponse.StatusCode); + Assert.False(string.IsNullOrEmpty(Assert.Single(legacyResponse.Headers.GetValues("Mcp-Session-Id")))); + } + + [Fact] + public async Task ModernPost_IgnoresMcpSessionIdHeader() + { + await StartHybridServerAsync(); + + // SEP-2567 removed sessions from the 2026-07-28 revision, so a stray session ID must neither be honored + // nor looked up against the stateful session manager the hybrid endpoint keeps for legacy clients. + using var response = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest, + mcpMethod: "server/discover", sessionId: "non-existent-session-id"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); + } + + [Fact] + public async Task LegacyGetAndDelete_RemainAvailable_WhileModernGetAndDeleteReturn405() + { + await StartHybridServerAsync(); + + using var initializeResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest); + var sessionId = Assert.Single(initializeResponse.Headers.GetValues("Mcp-Session-Id")); + + // The GET and DELETE endpoints are still mapped, so legacy clients keep the unsolicited-message stream + // and explicit session termination. + using var legacyGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId); + Assert.Equal(HttpStatusCode.OK, legacyGet.StatusCode); + + using var modernGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.July2026ProtocolVersion, content: null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, modernGet.StatusCode); + Assert.Equal(["POST"], modernGet.Content.Headers.Allow); + + using var modernDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.July2026ProtocolVersion, content: null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, modernDelete.StatusCode); + Assert.Equal(["POST"], modernDelete.Content.Headers.Allow); + + using var legacyDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId); + Assert.Equal(HttpStatusCode.OK, legacyDelete.StatusCode); + + // The session is gone, which proves the legacy DELETE was honored rather than short-circuited. + using var afterDelete = await SendAsync(HttpMethod.Post, McpProtocolVersions.November2025ProtocolVersion, ListToolsRequest, sessionId: sessionId); + Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode); + } + + private Task SendAsync( + HttpMethod method, + string? protocolVersion, + string? content = null, + string? mcpMethod = null, + string? sessionId = null) + { + var request = new HttpRequestMessage(method, ""); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + if (protocolVersion is not null) + { + request.Headers.Add("MCP-Protocol-Version", protocolVersion); + } + + if (mcpMethod is not null) + { + request.Headers.Add("Mcp-Method", mcpMethod); + } + + if (sessionId is not null) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + } + + if (content is not null) + { + request.Content = new StringContent(content, Encoding.UTF8, "application/json"); + } + + return HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + } + + private static string DiscoverRequest => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"HybridTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; + + private static string InitializeRequest => """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"HybridTestClient","version":"1.0"}}} + """; + + private static string ListToolsRequest => """ + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + """; + + public class ScopedService + { + public string? State { get; set; } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index d593ff2f2..142536b9d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -11,12 +11,13 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// End-to-end coverage for a default (2026-07-28-first) client connecting to a real C# Streamable HTTP -/// server that deliberately opted into sessions ( -/// is false). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports -/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with -32022 UnsupportedProtocolVersion. -/// The client must then auto-downgrade to the initialize handshake, obtain the stateful session -/// the server author opted into, and continue to work, including a server→client elicitation round-trip -/// resolved over the stateful session via the initialize-handshake backcompat resolver. +/// server that deliberately opted into sessions using either +/// or . Starting with the 2026-07-28 protocol revision, +/// Streamable HTTP no longer supports sessions (SEP-2567 / SEP-2575), so the server refuses the probe with +/// -32022 UnsupportedProtocolVersion. The client must then auto-downgrade to the initialize +/// handshake, obtain the stateful session the server author opted into, and continue to work, including a +/// server-to-client elicitation round-trip resolved over the stateful session via the initialize-handshake +/// backcompat resolver. /// public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { @@ -37,7 +38,7 @@ public async ValueTask DisposeAsync() [McpServerTool(Name = "greet_via_elicit")] private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) { - // Server→client round-trip: only works when the session is stateful, which is exactly what + // Server-to-client round-trip: only works when the session is stateful, which is exactly what // the initialize fallback re-establishes for the 2026-07-28-first client. var elicitResult = await server.ElicitAsync(new ElicitRequestParams { @@ -52,15 +53,19 @@ private static async Task GreetViaElicit(McpServer server, CancellationT return $"Hello, {name}!"; } - private async Task StartStatefulServerAsync() + private Task StartStatefulServerAsync() => + StartStatefulServerAsync(options => options.Stateless = false); + + private Task StartStatefulSessionModeServerAsync() => + StartStatefulServerAsync(options => options.SessionMode = HttpServerSessionMode.Stateful); + + private async Task StartStatefulServerAsync(Action configureTransport) { Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolStatefulFallbackTests), Version = "1" }; }) - // Stateless = false is a deliberate opt-in to sessions. Starting with the 2026-07-28 protocol revision, - // Streamable HTTP can never be served statefully, so the server refuses the probe and the client downgrades. - .WithHttpTransport(options => options.Stateless = false) + .WithHttpTransport(configureTransport) .WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]); _app = Builder.Build(); @@ -88,6 +93,19 @@ public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_And { await StartStatefulServerAsync(); + await AssertDefaultClientDowngradesAndToolsWorkAsync(); + } + + [Fact] + public async Task DefaultClient_AgainstStatefulSessionMode_DowngradesToInitialize_AndToolsWork() + { + await StartStatefulSessionModeServerAsync(); + + await AssertDefaultClientDowngradesAndToolsWorkAsync(); + } + + private async Task AssertDefaultClientDowngradesAndToolsWorkAsync() + { await using var client = await ConnectDefaultClientAsync(); // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 3d8abb0f1..03af131b4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): - // the handler refuses a request when the server opted into sessions (Stateless = false), so a client pinned + // the handler refuses a request when the server opted into sessions (SessionMode = HttpServerSessionMode.Stateful), so a client pinned // to that revision downgrades to legacy instead of negotiating 2026-07-28. These MRTR tests therefore can't // run on the stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures. private const string July2026StatefulStreamableHttpSkipReason = diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index aa724bb1b..df5d4bd03 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -293,7 +293,7 @@ static string (RequestContext context) => Name = "backcompat-roots-tool", Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", }), - ]).WithHttpTransport(options => options.Stateless = false); + ]).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); _app = Builder.Build(); _app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 8520f929c..0fce6dc08 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -441,7 +441,7 @@ public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Retur request.Headers.Accept.Add(new("text/event-stream")); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - // Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE + // SessionMode = HttpServerSessionMode.Stateless doesn't map the GET endpoint - per SEP-2567 the standalone SSE // stream is replaced by subscriptions/listen POST requests. Existing routing in // McpEndpointRouteBuilderExtensions only maps GET when Stateless == false. Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 73d000797..166622d74 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -35,10 +35,7 @@ private async Task StartAsync(bool stateless) { options.ServerInfo = new Implementation { Name = nameof(RequestAbortCancellationTests), Version = "1" }; }) - .WithHttpTransport(options => - { - options.Stateless = stateless; - }) + .WithHttpTransport(options => options.Stateless = stateless) .WithTools([McpServerTool.Create( async (CancellationToken cancellationToken) => { @@ -86,8 +83,8 @@ public async ValueTask DisposeAsync() public async Task July2026Request_AbortFlowsCancellationToToolHandler() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is - // served natively only on a stateless server; a Stateless=false server refuses these requests so dual-era - // clients fall back to initialize. + // served natively only on a stateless server; a stateful server refuses these requests to allow + // a client to fall back to `initialize` if it supports it. await StartAsync(stateless: true); using var request = CreateBlockingToolRequest(july2026Protocol: true); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 29e69483e..87218202f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -490,8 +490,8 @@ protected async Task CreateServerAsync( var serverBuilder = Builder.Services.AddMcpServer() .WithHttpTransport(options => { - // Resumability is a stateful concern; pin Stateless = false now that the new default is true. - options.Stateless = false; + // Resumability is a stateful concern; pin SessionMode = HttpServerSessionMode.Stateful since the session is required. + options.SessionMode = HttpServerSessionMode.Stateful; options.EventStreamStore = eventStreamStore; configureTransport?.Invoke(options); }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs index 7609e8215..56fe09bb7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs @@ -222,7 +222,7 @@ private async Task StartAsync(ISessionMigrationHandler? migrationHandler = null) Name = "SessionMigrationTestServer", Version = "1.0.0", }; - }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); + }).WithTools(Tools).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); if (migrationHandler is not null) { diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index f93b6ab2b..030f05657 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Connections; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Serilog; @@ -378,7 +379,7 @@ private static void HandleStatelessMcp(IApplicationBuilder app) serviceCollection.AddSingleton(app.ApplicationServices.GetRequiredService()); serviceCollection.AddRoutingCore(); - serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.Stateless = true); + serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless); var appBuilder = new ApplicationBuilder(serviceCollection.BuildServiceProvider()); appBuilder.UseRouting(); @@ -428,8 +429,8 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide .WithHttpTransport(options => { // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). - // Set Stateless = false explicitly now that the 2026-07-28 protocol (SEP-2567) defaults to true. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful explicitly since sessions are required. + options.SessionMode = HttpServerSessionMode.Stateful; options.EnableLegacySse = true; }); From 6fa3825973949a9c4f0cd8af344e15a8db09dc35 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 13 Aug 2026 01:23:07 -0700 Subject: [PATCH 9/9] Release v2.2.0 (#1813) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Directory.Build.props | 2 +- src/PACKAGE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0df4b7cce..2b120c434 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ https://csharp.sdk.modelcontextprotocol.io https://github.com/modelcontextprotocol/csharp-sdk git - 2.1.0 + 2.2.0 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 9f0ada8db..b3c1d9143 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -4,7 +4,7 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. -See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.1.0) for what's new in this version. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.2.0) for what's new in this version. ## Packages